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..90beda84 100644 --- a/.github/workflows/auto-close-pr.yml +++ b/.github/workflows/auto-close-pr.yml @@ -14,7 +14,8 @@ 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)." + if: ${{ !(startsWith(github.head_ref, 'release/') && github.event.pull_request.head.repo.full_name == github.repository) }} env: GH_REPO: ${{ github.repository }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/auto-create-release-pr.yml b/.github/workflows/auto-create-release-pr.yml new file mode 100644 index 00000000..78dfb844 --- /dev/null +++ b/.github/workflows/auto-create-release-pr.yml @@ -0,0 +1,50 @@ +name: Auto-create Release PR + +on: + push: + branches: + - 'release/**' + +jobs: + create-pr: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v4 + + - name: Create pull request + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + existing_pr_count="$(gh pr list \ + --state open \ + --base main \ + --head "${{ github.ref_name }}" \ + --json number \ + --jq 'length')" + if [ "${existing_pr_count}" -gt 0 ]; then + echo "Open pull request already exists for branch '${{ github.ref_name }}' into 'main'; skipping creation." + else + gh pr create \ + --title "$(git log -1 --pretty=%s)" \ + --body "Automated release PR." \ + --base main \ + --head "${{ github.ref_name }}" + fi + + - name: Approve pull request + env: + # PAT stored in github/CopilotForXcode, with write permissions to pull requests + GH_TOKEN: ${{ secrets.XCODE_AUTO_APPROVE }} + run: | + gh pr review --approve "${{ github.ref_name }}" + + - name: Auto-merge pull request + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh pr merge "${{ github.ref_name }}" \ + --auto \ + --delete-branch diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 78e35963..9c414bc1 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -27,6 +27,10 @@ jobs: fail-fast: false matrix: include: + - language: actions + build-mode: none + - language: javascript-typescript + build-mode: none - language: python build-mode: none - language: swift @@ -37,7 +41,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -66,6 +70,6 @@ jobs: CODE_SIGNING_ALLOWED="NO" - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@v4 with: category: "/language:${{matrix.language}}" diff --git a/CHANGELOG.md b/CHANGELOG.md index 765d3400..f380bf27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,55 @@ 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. 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..d232491a 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 */, @@ -837,7 +845,7 @@ "@executable_path/../Frameworks", "@executable_path/../../../../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = "$(APP_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).EditorExtension"; PRODUCT_NAME = Copilot; @@ -866,7 +874,7 @@ "@executable_path/../Frameworks", "@executable_path/../../../../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = "$(APP_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).EditorExtension"; PRODUCT_NAME = Copilot; @@ -928,7 +936,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -983,7 +991,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = macosx; @@ -1014,7 +1022,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = "$(APP_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE)"; PRODUCT_MODULE_NAME = Copilot_for_Xcode; @@ -1048,7 +1056,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = "$(APP_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE)"; PRODUCT_NAME = "$(HOST_APP_NAME)"; @@ -1064,7 +1072,7 @@ CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = VEKTX9H2N7; ENABLE_HARDENED_RUNTIME = YES; - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_VERSION = 5.0; @@ -1079,7 +1087,7 @@ DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=macosx*]" = VEKTX9H2N7; ENABLE_HARDENED_RUNTIME = YES; - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; @@ -1109,7 +1117,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = "$(APP_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).ExtensionService"; PRODUCT_NAME = "$(EXTENSION_SERVICE_NAME)"; @@ -1143,7 +1151,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = "$(APP_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).ExtensionService"; PRODUCT_NAME = "$(EXTENSION_SERVICE_NAME)"; @@ -1164,7 +1172,7 @@ ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).CommunicationBridge"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -1185,7 +1193,7 @@ ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).CommunicationBridge"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/Copilot for Xcode.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..f01bb0ad 100644 --- a/Copilot for Xcode/App.swift +++ b/Copilot for Xcode/App.swift @@ -21,23 +21,20 @@ class AppDelegate: NSObject, NSApplicationDelegate { case chat case settings case tools + case toolsAutoApprove case byok } func applicationDidFinishLaunching(_ notification: Notification) { - if #available(macOS 13.0, *) { - checkBackgroundPermissions() - } - + checkBackgroundPermissions() + let launchMode = determineLaunchMode() handleLaunchMode(launchMode) } func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { - if #available(macOS 13.0, *) { - checkBackgroundPermissions() - } - + checkBackgroundPermissions() + let launchMode = determineLaunchMode() handleLaunchMode(launchMode) return true @@ -51,6 +48,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 +63,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { openSettings() case .tools: openToolsSettings() + case .toolsAutoApprove: + openToolsSettingsAutoApprove() case .byok: openBYOKSettings() case .chat: @@ -92,6 +93,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 { @@ -100,7 +109,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } - @available(macOS 13.0, *) private func checkBackgroundPermissions() { Task { // Direct check of permission status @@ -109,7 +117,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { if !isPermissionGranted { // Only show alert if permission isn't granted - DispatchQueue.main.async { + await MainActor.run { if !self.permissionAlertShown { showBackgroundPermissionAlert() self.permissionAlertShown = true @@ -117,7 +125,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } else { // Permission is granted, reset flag - self.permissionAlertShown = false + await MainActor.run { + self.permissionAlertShown = false + } } } } @@ -202,6 +212,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 +235,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 { @@ -236,10 +269,8 @@ func activateAndOpenSettings() { if #available(macOS 14.0, *) { let environment = SettingsEnvironment() environment.open() - } else if #available(macOS 13.0, *) { - NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) } else { - NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil) + NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) } } diff --git a/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..08ce8d4a 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -8,7 +8,7 @@ import PackageDescription let package = Package( name: "Core", - platforms: [.macOS(.v12)], + platforms: [.macOS(.v13)], products: [ .library( name: "Service", @@ -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 3f65d49b..f69afe52 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,16 +49,29 @@ struct ToolCallRequest { let completion: (AnyJSONRPCResponse) -> Void } +struct ConversationTurnTrackingState { + var turnParentMap: [String: String] = [:] // Maps subturn ID to parent turn ID + var validConversationIds: Set = [] // Tracks all valid conversation IDs including subagents + + mutating func reset() { + turnParentMap.removeAll() + validConversationIds.removeAll() + } +} + public final class ChatService: ChatServiceType, ObservableObject { public var memory: ContextAwareAutoManagedChatMemory @Published public internal(set) var chatHistory: [ChatMessage] = [] @Published public internal(set) var isReceivingMessage = false + @Published public internal(set) var isSummarizingConversation = false @Published public internal(set) var fileEditMap: OrderedDictionary = [:] + @Published public internal(set) var contextSizeInfo: ContextSizeInfo? = nil public internal(set) var requestType: RequestType? = nil public private(set) var chatTabInfo: ChatTabInfo private let conversationProvider: ConversationServiceProvider? private let conversationProgressHandler: ConversationProgressHandler + private let compressionHandler: CompressionHandler private let conversationContextHandler: ConversationContextHandler = ConversationContextHandlerImpl.shared // sync all the files in the workspace to watch for changes. private let watchedFilesHandler: WatchedFilesHandler = WatchedFilesHandlerImpl.shared @@ -68,13 +82,18 @@ 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, + compressionHandler: CompressionHandler = CompressionHandlerImpl.shared, chatTabInfo: ChatTabInfo) { self.memory = memory self.conversationProvider = provider self.conversationProgressHandler = conversationProgressHandler + self.compressionHandler = compressionHandler self.chatTabInfo = chatTabInfo memory.chatService = self @@ -120,6 +139,19 @@ public final class ChatService: ChatServiceType, ObservableObject { conversationProgressHandler.onEnd.sink { [weak self] (token, progress) in self?.handleProgressEnd(token: token, progress: progress) }.store(in: &cancellables) + + compressionHandler.onCompressionStarted.sink { [weak self] compressionConversationId in + guard let self, self.conversationId == compressionConversationId else { return } + self.isSummarizingConversation = true + }.store(in: &cancellables) + + compressionHandler.onCompressionCompleted.sink { [weak self] completedNotification in + guard let self, self.conversationId == completedNotification.conversationId else { return } + self.isSummarizingConversation = false + if let contextInfo = completedNotification.contextInfo { + self.contextSizeInfo = contextInfo + } + }.store(in: &cancellables) } private func subscribeToConversationContextRequest() { @@ -135,28 +167,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([ @@ -172,11 +195,11 @@ public final class ChatService: ChatServiceType, ObservableObject { return } - copilotTool.invokeTool(request, completion: completion, contextProvider: self) + _ = copilotTool.invokeTool(request, completion: completion, contextProvider: self) }).store(in: &cancellables) } - func appendToolCallHistory(turnId: String, editAgentRounds: [AgentRound], fileEdits: [FileEdit] = []) { + func appendToolCallHistory(turnId: String, editAgentRounds: [AgentRound], fileEdits: [FileEdit] = [], parentTurnId: String? = nil) { let chatTabId = self.chatTabInfo.id Task { let turnStatus: ChatMessage.TurnStatus? = { @@ -195,6 +218,7 @@ public final class ChatService: ChatServiceType, ObservableObject { assistantMessageWithId: turnId, chatTabID: chatTabId, editAgentRounds: editAgentRounds, + parentTurnId: parentTurnId, fileEdits: fileEdits, turnStatus: turnStatus ) @@ -228,75 +252,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) { - // Send the tool call result back to the server - if let toolCallRequest = self.pendingToolCallRequests[toolCallId], status == .accepted || status == .cancelled { + // 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: status == .accepted ? .Accept : .Dismiss - ) - 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) @@ -318,6 +345,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 { @@ -423,6 +451,7 @@ public final class ChatService: ChatServiceType, ObservableObject { model: model, modelProviderName: modelProviderName, agentMode: agentMode, + customChatModeId: customChatModeId, userLanguage: userLanguage, turnId: currentTurnId, skillSet: validSkillSet @@ -430,7 +459,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( @@ -442,6 +482,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] @@ -467,10 +508,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: []) @@ -535,6 +588,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 ) @@ -652,8 +706,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 }) { @@ -677,10 +745,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, turnStatus: .inProgress) + /// 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) + } } } @@ -688,12 +763,17 @@ public final class ChatService: ChatServiceType, ObservableObject { guard let workDownToken = activeRequestId, workDownToken == token else { return } - + + if let contextSize = progress.contextSize { + self.contextSizeInfo = contextSize + } + let id = progress.turnId var content = "" var references: [ConversationReference] = [] var steps: [ConversationProgressStep] = [] var editAgentRounds: [AgentRound] = [] + let parentTurnId = progress.parentTurnId if let reply = progress.reply { content = reply @@ -711,15 +791,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( @@ -729,10 +809,10 @@ public final class ChatService: ChatServiceType, ObservableObject { references: messageReferences, steps: messageSteps, editAgentRounds: messageAgentRounds, + parentTurnId: messageParentTurnId, turnStatus: .inProgress ) - // will persist in resetOngoingRequest() await memory.appendMessage(message) } } @@ -801,10 +881,16 @@ public final class ChatService: ChatServiceType, ObservableObject { } } 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) @@ -831,7 +917,11 @@ public final class ChatService: ChatServiceType, ObservableObject { private func resetOngoingRequest(with turnStatus: ChatMessage.TurnStatus = .success) { activeRequestId = nil isReceivingMessage = false + isSummarizingConversation = false requestType = nil + + // Clear turn tracking data + conversationTurnTracking.reset() // cancel all pending tool call requests for (_, request) in pendingToolCallRequests { @@ -874,6 +964,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, @@ -922,7 +1026,7 @@ 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(with: .error) @@ -946,12 +1050,51 @@ public final class ChatService: ChatServiceType, ObservableObject { } } } + + // MARK: - Certificate Error Detection + + /// 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 + } + + 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) + } } 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() @@ -980,6 +1123,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 diff --git a/Core/Sources/ChatService/Extensions/ChatService+FileEdit.swift b/Core/Sources/ChatService/Extensions/ChatService+FileEdit.swift index df1509ff..c901a341 100644 --- a/Core/Sources/ChatService/Extensions/ChatService+FileEdit.swift +++ b/Core/Sources/ChatService/Extensions/ChatService+FileEdit.swift @@ -25,7 +25,7 @@ extension ChatService { switch fileEdit.toolName { case .insertEditIntoFile: - InsertEditIntoFileTool.applyEdit(for: fileURL, content: fileEdit.originalContent, contextProvider: self) + InsertEditIntoFileTool.applyEdit(for: fileURL, content: fileEdit.originalContent) case .createFile: try CreateFileTool.undo(for: fileURL) default: 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/GetErrorsTool.swift b/Core/Sources/ChatService/ToolCalls/GetErrorsTool.swift index f41aa524..3a464016 100644 --- a/Core/Sources/ChatService/ToolCalls/GetErrorsTool.swift +++ b/Core/Sources/ChatService/ToolCalls/GetErrorsTool.swift @@ -33,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/InsertEditIntoFileTool.swift b/Core/Sources/ChatService/ToolCalls/InsertEditIntoFileTool.swift index 29ee7bef..2eb6b160 100644 --- a/Core/Sources/ChatService/ToolCalls/InsertEditIntoFileTool.swift +++ b/Core/Sources/ChatService/ToolCalls/InsertEditIntoFileTool.swift @@ -7,6 +7,34 @@ 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 @@ -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, @@ -86,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 @@ -113,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, @@ -128,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 { @@ -177,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/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 19e195be..3c570890 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,11 +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, @@ -47,11 +51,14 @@ public struct DisplayedChatMessage: Equatable { errorMessages: [String] = [], steps: [ConversationProgressStep] = [], editAgentRounds: [AgentRound] = [], + parentTurnId: String? = nil, panelMessages: [CopilotShowMessageParams] = [], codeReviewRound: CodeReviewRound? = nil, fileEdits: [FileEdit] = [], turnStatus: ChatMessage.TurnStatus? = nil, - requestType: RequestType + requestType: RequestType, + modelName: String? = nil, + billingMultiplier: Float? = nil ) { self.id = id self.role = role @@ -63,11 +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 } } @@ -195,19 +205,22 @@ struct Chat { 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 + 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 { @@ -282,16 +295,22 @@ struct Chat { struct ConversationState: Equatable { var history: [DisplayedChatMessage] var isReceivingMessage: Bool + var isSummarizingConversation: Bool var requestType: RequestType? + var contextSizeInfo: ContextSizeInfo? init( history: [DisplayedChatMessage] = [], isReceivingMessage: Bool = false, - requestType: RequestType? = nil + isSummarizingConversation: Bool = false, + requestType: RequestType? = nil, + contextSizeInfo: ContextSizeInfo? = nil ) { self.history = history self.isReceivingMessage = isReceivingMessage + self.isSummarizingConversation = isSummarizingConversation self.requestType = requestType + self.contextSizeInfo = contextSizeInfo } func subsequentMessages(after messageId: MessageID) -> [DisplayedChatMessage] { @@ -329,13 +348,16 @@ struct Chat { struct EnvironmentState: Equatable { var isAgentMode: Bool var workspaceURL: URL? + var selectedAgent: ConversationMode init( isAgentMode: Bool = AppState.shared.isAgentModeEnabled(), - workspaceURL: URL? = nil + workspaceURL: URL? = nil, + selectedAgent: ConversationMode = .defaultAgent ) { self.isAgentMode = isAgentMode self.workspaceURL = workspaceURL + self.selectedAgent = selectedAgent } } @@ -383,6 +405,7 @@ struct Chat { diffViewerController: DiffViewWindowController? = nil, isAgentMode: Bool = AppState.shared.isAgentModeEnabled(), workspaceURL: URL? = nil, + selectedAgent: ConversationMode = .defaultAgent, chatMenu: ChatMenu.State = .init(), codeReviewState: ConversationCodeReviewFeature.State = .init() ) { @@ -404,7 +427,8 @@ struct Chat { ), environment: EnvironmentState( isAgentMode: isAgentMode, - workspaceURL: workspaceURL + workspaceURL: workspaceURL, + selectedAgent: selectedAgent ), chatMenu: chatMenu, codeReviewState: codeReviewState @@ -436,11 +460,26 @@ struct Chat { set { conversation.isReceivingMessage = newValue } } + var isSummarizingConversation: Bool { + get { conversation.isSummarizingConversation } + set { conversation.isSummarizingConversation = newValue } + } + var requestType: RequestType? { get { conversation.requestType } set { conversation.requestType = newValue } } + var contextSizeInfo: ContextSizeInfo? { + get { conversation.contextSizeInfo } + set { conversation.contextSizeInfo = newValue } + } + + var handOffClicked: Bool { + get { editor.handOffClicked } + set { editor.handOffClicked = newValue } + } + var focusedField: Field? { get { editor.focusedField } set { editor.focusedField = newValue } @@ -487,6 +526,11 @@ struct Chat { 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) @@ -554,6 +598,7 @@ struct Chat { case copyCode(MessageID) case insertCode(String) case toolCallAccepted(String) + case toolCallAcceptedWithApproval(String, ToolAutoApprovalManager.AutoApproval?) case toolCallCompleted(String, String) case toolCallCancelled(String) @@ -561,10 +606,12 @@ struct Chat { case observeHistoryChange case observeIsReceivingMessageChange case observeFileEditChange + case observeContextSizeInfoChange case historyChanged case isReceivingMessageChanged case fileEditChanged + case contextSizeInfoChanged case chatMenu(ChatMenu.Action) @@ -579,6 +626,7 @@ struct Chat { case removeSelectedImage(ImageReference) case followUpButtonClicked(String, String) + case handOffButtonClicked(HandOff) // Agent File Edit case undoEdits(fileURLs: [URL]) @@ -589,6 +637,7 @@ struct Chat { case setDiffViewerController(chat: StoreOf) case agentModeChanged(Bool) + case selectedAgentChanged(ConversationMode) // Code Review case codeReview(ConversationCodeReviewFeature.Action) @@ -608,6 +657,8 @@ struct Chat { case undoCheckPoint // Revert the restore case discardCheckPoint case reloadWorkingset(DisplayedChatMessage) + + case openAutoApproveSettings } let service: ChatService @@ -618,6 +669,7 @@ struct Chat { case observeIsReceivingMessageChange(UUID) case sendMessage(UUID) case observeFileEditChange(UUID) + case observeContextSizeInfoChange(UUID) case observeFixErrorNotification(UUID) } @@ -647,11 +699,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)) + } } } @@ -673,6 +737,7 @@ 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 @@ -708,6 +773,7 @@ struct Chat { model: selectedModelFamily, modelProviderName: selectedModel?.providerName, agentMode: agentMode, + customChatModeId: selectedAgentSubMode, userLanguage: chatResponseLocale ) }.cancellable(id: CancelID.sendMessage(self.id)) @@ -717,6 +783,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 @@ -740,6 +817,7 @@ struct Chat { )?.modelFamily let references = state.attachedReferences let agentMode = AppState.shared.isAgentModeEnabled() + let selectedAgentSubMode = AppState.shared.getSelectedAgentSubMode() return .run { send in await send(.resetContextProvider) @@ -754,9 +832,34 @@ 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" @@ -858,6 +961,7 @@ struct Chat { await send(.observeHistoryChange) await send(.observeIsReceivingMessageChange) await send(.observeFileEditChange) + await send(.observeContextSizeInfoChange) } case .observeHistoryChange: @@ -883,6 +987,7 @@ struct Chat { return .run { send in let stream = AsyncStream { continuation in let cancellable = service.$isReceivingMessage + .merge(with: service.$isSummarizingConversation) .sink { _ in continuation.yield() } @@ -917,6 +1022,25 @@ struct Chat { cancelInFlight: true ) + case .observeContextSizeInfoChange: + return .run { send in + let stream = AsyncStream { continuation in + let cancellable = service.$contextSizeInfo + .sink { _ in + continuation.yield() + } + continuation.onTermination = { _ in + cancellable.cancel() + } + } + for await _ in stream { + await send(.contextSizeInfoChanged) + } + }.cancellable( + id: CancelID.observeContextSizeInfoChange(id), + cancelInFlight: true + ) + case .historyChanged: state.history = service.chatHistory.flatMap { message in var all = [DisplayedChatMessage]() @@ -944,11 +1068,14 @@ struct Chat { errorMessages: message.errorMessages, steps: message.steps, editAgentRounds: message.editAgentRounds, + parentTurnId: message.parentTurnId, panelMessages: message.panelMessages, codeReviewRound: message.codeReviewRound, fileEdits: message.fileEdits, turnStatus: message.turnStatus, - requestType: message.requestType + requestType: message.requestType, + modelName: message.modelName, + billingMultiplier: message.billingMultiplier )) return all @@ -958,9 +1085,14 @@ struct Chat { case .isReceivingMessageChanged: state.isReceivingMessage = service.isReceivingMessage + state.isSummarizingConversation = service.isSummarizingConversation state.requestType = service.requestType return .none - + + case .contextSizeInfoChanged: + state.conversation.contextSizeInfo = service.contextSizeInfo + return .none + case .fileEditChanged: state.fileEditMap = service.fileEditMap let fileEditMap = state.fileEditMap @@ -1091,7 +1223,12 @@ 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 @@ -1184,6 +1321,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( @@ -1194,6 +1333,7 @@ struct Chat { model: selectedModelFamily, modelProviderName: selectedModel?.providerName, agentMode: agentMode, + customChatModeId: selectedAgentSubMode, userLanguage: chatResponseLocale ) }.cancellable(id: CancelID.sendMessage(self.id)) @@ -1335,6 +1475,11 @@ struct Chat { service.updateFileEdits(by: fileEdit) } } + + case .openAutoApproveSettings: + return .run { _ in + try launchHostAppToolsSettingsAutoApprove() + } } } } diff --git a/Core/Sources/ConversationTab/ChatPanel.swift b/Core/Sources/ConversationTab/ChatPanel.swift index 0cc83479..ed1498f1 100644 --- a/Core/Sources/ConversationTab/ChatPanel.swift +++ b/Core/Sources/ConversationTab/ChatPanel.swift @@ -39,8 +39,15 @@ public struct ChatPanel: View { 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) .scaledPadding(.vertical, 8) .scaledPadding(.horizontal, 16) @@ -51,7 +58,7 @@ public struct ChatPanel: View { if chat.fileEditMap.count > 0 { WorkingSetView(chat: chat) .dimWithExitEditMode(chat) - .scaledPadding(.horizontal, 16) + .scaledPadding(.horizontal, 24) } ChatPanelInputArea(chat: chat, r: r, editorMode: .input) @@ -151,6 +158,7 @@ struct ChatPanelMessages: View { Group { ChatHistory(chat: chat) + .fixedSize(horizontal: false, vertical: true) ExtraSpacingInResponding(chat: chat) @@ -175,24 +183,13 @@ struct ChatPanelMessages: View { ) }) } - .modify { view in - if #available(macOS 13.0, *) { - view - .listRowSeparator(.hidden) - } else { - view - } - } + .listRowSeparator(.hidden) } .listStyle(.plain) .scaledPadding(.leading, 8) .listRowBackground(EmptyView()) .modify { view in - if #available(macOS 13.0, *) { - view.scrollContentBackground(.hidden) - } else { - view - } + view.scrollContentBackground(.hidden) } .coordinateSpace(name: scrollSpace) .preference( @@ -302,11 +299,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) } } } @@ -404,11 +403,16 @@ struct ChatHistory: View { } if message.role != .ignored && index < currentFilteredHistory.count - 1 { - if message.role == .assistant { - // check point - CheckPoint(chat: chat, messageId: message.id) - .padding(.vertical, 8) - .padding(.trailing, 8) + 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) + } } } @@ -486,12 +490,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) 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/FilePicker.swift b/Core/Sources/ConversationTab/FilePicker.swift index ca2e3494..284c52ec 100644 --- a/Core/Sources/ConversationTab/FilePicker.swift +++ b/Core/Sources/ConversationTab/FilePicker.swift @@ -203,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 58% rename from Core/Sources/ConversationTab/ModelPicker/ModelPicker.swift rename to Core/Sources/ConversationTab/ModeAndModelPicker/ModeAndModelPickerPicker.swift index 937bc5bd..7e03aad1 100644 --- a/Core/Sources/ConversationTab/ModelPicker/ModelPicker.swift +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModeAndModelPickerPicker.swift @@ -8,15 +8,15 @@ 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 @ObservedObject private var modelManager = CopilotModelManagerObservable.shared static var lastRefreshModelsTime: Date = .init(timeIntervalSince1970: 0) @State private var chatMode = "Ask" - @State private var isAgentPickerHovered = false // Separate caches for both scopes @State private var askScopeCache: ScopeCache = ScopeCache() @@ -25,25 +25,12 @@ struct ModelPicker: View { @State var isMCPFFEnabled: Bool @State var isBYOKFFEnabled: Bool @State private var cancellables = Set() - - @StateObject private var fontScaleManager = FontScaleManager.shared - - var fontScale: Double { - 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) @@ -79,26 +66,11 @@ struct ModelPicker: View { AppState.shared.isAgentModeEnabled() ? agentScopeCache : askScopeCache } - // Helper method to format multiplier text - func formatMultiplierText(for billing: CopilotModelBilling?) -> String { - guard let billingInfo = billing else { return "" } - - let multiplier = billingInfo.multiplier - if multiplier == 0 { - return "Included" - } else { - let numberPart = multiplier.truncatingRemainder(dividingBy: 1) == 0 - ? String(format: "%.0f", multiplier) - : String(format: "%.2f", multiplier) - return "\(numberPart)x" - } - } - // Update cache for specific scope only if models changed func updateModelCacheIfNeeded(for scope: PromptTemplateScope) { - let currentModels = scope == .agentPanel ? - modelManager.availableAgentModels + modelManager.availableAgentBYOKModels : - modelManager.availableChatModels + modelManager.availableChatBYOKModels + let clsModels = scope == .agentPanel ? modelManager.availableAgentModels : modelManager.availableChatModels + let byokModels = isBYOKFFEnabled ? (scope == .agentPanel ? modelManager.availableAgentBYOKModels : modelManager.availableChatBYOKModels) : [] + let currentModels = clsModels + byokModels let modelsHash = currentModels.hashValue if scope == .agentPanel { @@ -116,19 +88,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,12 +116,13 @@ struct ModelPicker: View { allAvailableModels += byokModels } - // Check if current model exists in available models for current scope using model comparison - let modelExists = allAvailableModels.contains { model in + // Find the fresh model from available models that matches the persisted selection. + // This ensures transient fields like degradationReason stay up to date. + let freshModel = allAvailableModels.first { model in model == currentModel } - - if !modelExists && currentModel != nil { + + if freshModel == nil && currentModel != nil { // Switch to default model if current model is not available if let fallbackModel = defaultModel { AppState.shared.setSelectedModel(fallbackModel) @@ -168,7 +135,7 @@ struct ModelPicker: View { selectedModel = nil } } else { - selectedModel = currentModel ?? defaultModel + selectedModel = freshModel ?? defaultModel } } @@ -176,11 +143,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,82 +175,53 @@ struct ModelPicker: View { updateModelCacheIfNeeded(for: scope) } - // Model picker menu component - private var modelPickerMenu: some View { - Menu { - // Group models by premium status - let premiumModels = copilotModels.filter { - $0.billing?.isPremium == true - } - let standardModels = copilotModels.filter { - $0.billing?.isPremium == false || $0.billing == nil - } - - // Display standard models section if available - modelSection(title: "Standard Models", models: standardModels) + // 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 { - // Display premium models section if available - modelSection(title: "Premium Models", models: premiumModels) + let displayNameEndIndex = modelString.index(before: openParenIndex) + displayName = String(modelString[.. some View { - if !models.isEmpty { - Section(title) { - ForEach(models, id: \.self) { model in - modelButton(for: model) - } + + // Search in available models + return availableModels.first { model in + let matchesDisplayName = (model.displayName ?? model.modelName) == displayName + + if isCopilotModel { + // For Copilot models, providerName should be nil or empty + return matchesDisplayName && (model.providerName == nil || model.providerName?.isEmpty == true) + } else { + // For BYOK models, providerName should match (case-insensitive) + guard let modelProvider = model.providerName else { return false } + return matchesDisplayName && modelProvider.lowercased() == provider.lowercased() } } } - // Helper function to create a model selection button - private func modelButton(for model: LLMModel) -> some View { - Button { - AppState.shared.setSelectedModel(model) - } label: { - Text(createModelMenuItemAttributedString( - modelName: model.displayName ?? model.modelName, - isSelected: selectedModel == model, - cachedMultiplierText: currentCache.modelMultiplierCache[model.modelName.appending(model.providerName ?? "")] ?? "" - )) - } - } - private var mcpButton: some View { Group { if isMCPFFEnabled { Button(action: { - try? launchHostAppToolsSettings() + let currentSubMode = AppState.shared.getSelectedAgentSubMode() + try? launchHostAppToolsSettings(currentAgentSubMode: currentSubMode) }) { mcpIcon.foregroundColor(.primary.opacity(0.85)) } @@ -301,7 +251,12 @@ struct ModelPicker: View { WithPerceptionTracking { HStack(spacing: 0) { // Custom segmented control with color change - ChatModePicker(chatMode: $chatMode, onScopeChange: switchModelsForScope) + ChatModePicker( + projectRootURL: projectRootURL, + chatMode: $chatMode, + selectedAgent: $selectedAgent, + onScopeChange: switchModelsForScope + ) .onAppear { updateAgentPicker() } @@ -317,7 +272,13 @@ struct ModelPicker: View { // Model Picker Group { if !copilotModels.isEmpty && selectedModel != nil { - modelPickerMenu + ChatModelPicker( + selectedModel: selectedModel, + copilotModels: copilotModels, + byokModels: byokModels, + isBYOKFFEnabled: isBYOKFFEnabled, + currentCache: currentCache + ) } else { EmptyView() } @@ -366,15 +327,6 @@ struct ModelPicker: View { } } - func labelWidth() -> CGFloat { - guard let selectedModel = selectedModel else { return 100 } - let displayName = selectedModel.displayName ?? selectedModel.modelName - let width = displayName.size( - withAttributes: attributes - ).width - return CGFloat(width * fontScale + 20) - } - @MainActor func refreshModels() async { let now = Date() @@ -389,42 +341,12 @@ struct ModelPicker: View { } } - private func createModelMenuItemAttributedString( - modelName: String, - isSelected: Bool, - cachedMultiplierText: String - ) -> 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 - } } 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..5ed180f8 --- /dev/null +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeButtonMenuItem.swift @@ -0,0 +1,506 @@ +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 { + ModelMenuItemFormatter.drawMenuItemHighlight( + in: frame, + fontScale: fontScale, + hoverEdgeInset: scaledConstants.hoverEdgeInset + ) + } + } + + 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..641a4489 --- /dev/null +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/ChatModePicker.swift @@ -0,0 +1,294 @@ +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 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 + 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 + }) + .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: 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: 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 && !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 72% rename from Core/Sources/ConversationTab/ModelPicker/ModelManagerUtils.swift rename to Core/Sources/ConversationTab/ModeAndModelPicker/ModelManagerUtils.swift index 92af4af9..3e0100e7 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,14 +26,16 @@ 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 } let displayName = savedModel["displayName"]?.stringValue let providerName = savedModel["providerName"]?.stringValue let supportVision = savedModel["supportVision"]?.boolValue ?? false - + let degradationReason = savedModel["degradationReason"]?.stringValue + // Try to reconstruct billing info if available var billing: CopilotModelBilling? if let isPremium = savedModel["billing"]?["isPremium"]?.boolValue, @@ -42,20 +45,24 @@ public extension AppState { multiplier: Float(multiplier) ) } - + return LLMModel( displayName: displayName, modelName: modelName, modelFamily: modelFamily, + id: id, billing: billing, providerName: providerName, - supportVision: supportVision + supportVision: supportVision, + degradationReason: degradationReason ) } 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 +86,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,9 +153,11 @@ 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 + supportVision: fallbackModel.capabilities.supports.vision, + degradationReason: fallbackModel.degradationReason ) ) } @@ -154,8 +176,10 @@ 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 + supportVision: $0.capabilities.supports.vision, + degradationReason: $0.degradationReason ) } } @@ -163,14 +187,17 @@ 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 }) + ?? LLMsInScope.first(where: { $0.isChatDefault }) // 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 + supportVision: defaultModel.capabilities.supports.vision, + degradationReason: defaultModel.degradationReason ) } @@ -180,8 +207,10 @@ 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 + supportVision: gpt4_1.capabilities.supports.vision, + degradationReason: gpt4_1.degradationReason ) } @@ -190,8 +219,10 @@ public extension CopilotModelManager { return LLMModel( modelName: firstModel.modelName, modelFamily: firstModel.modelFamily, + id: firstModel.id, billing: firstModel.billing, - supportVision: firstModel.capabilities.supports.vision + supportVision: firstModel.capabilities.supports.vision, + degradationReason: firstModel.degradationReason ) } @@ -213,6 +244,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,32 +254,66 @@ 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 let degradationReason: String? public init( displayName: String? = nil, modelName: String, modelFamily: String, + id: String, billing: CopilotModelBilling?, providerName: String? = nil, - supportVision: Bool + supportVision: Bool, + degradationReason: String? = nil ) { self.displayName = displayName self.modelName = modelName self.modelFamily = modelFamily + self.id = id self.billing = billing self.providerName = providerName self.supportVision = supportVision + self.degradationReason = degradationReason + } + + // Exclude degradationReason from equality β€” it's transient status, not model identity + public static func == (lhs: LLMModel, rhs: LLMModel) -> Bool { + lhs.displayName == rhs.displayName && + lhs.modelName == rhs.modelName && + lhs.modelFamily == rhs.modelFamily && + lhs.id == rhs.id && + lhs.billing == rhs.billing && + lhs.providerName == rhs.providerName && + lhs.supportVision == rhs.supportVision } + + public func hash(into hasher: inout Hasher) { + hasher.combine(displayName) + hasher.combine(modelName) + hasher.combine(modelFamily) + hasher.combine(id) + hasher.combine(billing) + hasher.combine(providerName) + hasher.combine(supportVision) + } +} + +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" } } -public struct ScopeCache { - var modelMultiplierCache: [String: String] = [:] - var cachedMaxWidth: CGFloat = 0 - var lastModelsHash: Int = 0 +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..e97027cc --- /dev/null +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelMenuItemFormatter.swift @@ -0,0 +1,128 @@ +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 = 24 + + 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, + isDegraded: Bool = false + ) -> AttributedString { + let prefix: String + if isDegraded { + prefix = "⚠ " + } else if isSelected { + prefix = "βœ“ " + } else { + prefix = " " + } + let displayName = "\(prefix)\(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 "" + } + } + + /// Draws the standard menu-item highlight background (accent-colored rounded rect). + static func drawMenuItemHighlight( + in frame: NSRect, + fontScale: Double, + hoverEdgeInset: CGFloat + ) { + NSGraphicsContext.saveGraphicsState() + NSColor.controlAccentColor.setFill() + + let cornerRadius: CGFloat + if #available(macOS 26.0, *) { + cornerRadius = 8.0 * fontScale + } else { + cornerRadius = 4.0 * fontScale + } + + let hoverWidth = frame.width - (hoverEdgeInset * 2) + let insetRect = NSRect( + x: hoverEdgeInset, + y: 0, + width: hoverWidth, + height: frame.height + ) + let path = NSBezierPath( + roundedRect: insetRect, + xRadius: cornerRadius, + yRadius: cornerRadius + ) + path.fill() + NSGraphicsContext.restoreGraphicsState() + } +} diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ChatModelPicker.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ChatModelPicker.swift new file mode 100644 index 00000000..be81b51a --- /dev/null +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ChatModelPicker.swift @@ -0,0 +1,28 @@ +import SharedUIComponents +import SwiftUI + +struct ChatModelPicker: View { + let selectedModel: LLMModel? + let copilotModels: [LLMModel] + let byokModels: [LLMModel] + let isBYOKFFEnabled: Bool + let currentCache: ScopeCache + + @StateObject private var fontScaleManager = FontScaleManager.shared + + private var fontScale: Double { + fontScaleManager.currentScale + } + + var body: some View { + ModelPickerButton( + selectedModel: selectedModel, + copilotModels: copilotModels, + byokModels: byokModels, + isBYOKFFEnabled: isBYOKFFEnabled, + currentCache: currentCache, + fontScale: fontScale + ) + .fixedSize(horizontal: false, vertical: true) + } +} diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ModelPickerButton.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ModelPickerButton.swift new file mode 100644 index 00000000..d2a92b5f --- /dev/null +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ModelPickerButton.swift @@ -0,0 +1,270 @@ +import AppKit +import SwiftUI + +// MARK: - Model Picker Button (NSViewRepresentable) + +struct ModelPickerButton: NSViewRepresentable { + let selectedModel: LLMModel? + let copilotModels: [LLMModel] + let byokModels: [LLMModel] + let isBYOKFFEnabled: Bool + let currentCache: ScopeCache + let fontScale: Double + + func makeNSView(context: Context) -> NSView { + let container = ModelPickerContainerView(fontScale: fontScale) + container.translatesAutoresizingMaskIntoConstraints = false + + let button = ClickThroughButton() + button.title = "" + button.bezelStyle = .inline + button.setButtonType(.momentaryPushIn) + button.isBordered = false + button.target = context.coordinator + button.action = #selector(Coordinator.buttonClicked(_:)) + button.translatesAutoresizingMaskIntoConstraints = false + button.wantsLayer = true + + let titleLabel = NSTextField(labelWithString: "") + titleLabel.isEditable = false + titleLabel.isBordered = false + titleLabel.backgroundColor = .clear + titleLabel.drawsBackground = false + titleLabel.translatesAutoresizingMaskIntoConstraints = false + titleLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + titleLabel.alignment = .center + titleLabel.usesSingleLineMode = true + titleLabel.lineBreakMode = .byTruncatingMiddle + + let chevronView = NSImageView() + let chevronImage = NSImage( + systemSymbolName: "chevron.down", + accessibilityDescription: nil + ) + let symbolConfig = NSImage.SymbolConfiguration( + pointSize: 8 * fontScale, weight: .semibold + ) + chevronView.image = chevronImage?.withSymbolConfiguration(symbolConfig) + chevronView.translatesAutoresizingMaskIntoConstraints = false + + let stackView = NSStackView(views: [titleLabel, chevronView]) + stackView.orientation = .horizontal + stackView.spacing = 2 * fontScale + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.alignment = .centerY + stackView.setHuggingPriority(.required, for: .horizontal) + + button.addSubview(stackView) + container.addSubview(button) + + NSLayoutConstraint.activate([ + button.leadingAnchor.constraint(equalTo: container.leadingAnchor), + button.trailingAnchor.constraint(equalTo: container.trailingAnchor), + button.topAnchor.constraint(equalTo: container.topAnchor), + button.bottomAnchor.constraint(equalTo: container.bottomAnchor), + + stackView.leadingAnchor.constraint( + equalTo: button.leadingAnchor, constant: 6 * fontScale + ), + stackView.trailingAnchor.constraint( + equalTo: button.trailingAnchor, constant: -6 * fontScale + ), + stackView.topAnchor.constraint( + equalTo: button.topAnchor, constant: 2 * fontScale + ), + stackView.bottomAnchor.constraint( + equalTo: button.bottomAnchor, constant: -2 * fontScale + ), + + chevronView.widthAnchor.constraint(equalToConstant: 8 * fontScale), + chevronView.heightAnchor.constraint(equalToConstant: 8 * fontScale), + ]) + + context.coordinator.button = button + context.coordinator.titleLabel = titleLabel + context.coordinator.chevronView = chevronView + + // Setup tracking for hover + let trackingArea = NSTrackingArea( + rect: .zero, + options: [.mouseEnteredAndExited, .activeInActiveApp, .inVisibleRect], + owner: context.coordinator, + userInfo: nil + ) + button.addTrackingArea(trackingArea) + context.coordinator.trackingArea = trackingArea + + return container + } + + func updateNSView(_ nsView: NSView, context: Context) { + guard let titleLabel = context.coordinator.titleLabel, + let button = context.coordinator.button, + let chevronView = context.coordinator.chevronView + else { return } + + let label = selectedModelLabel + titleLabel.stringValue = label + titleLabel.font = NSFont.systemFont(ofSize: 13 * fontScale) + titleLabel.textColor = .labelColor + + let chevronConfig = NSImage.SymbolConfiguration( + pointSize: 8 * fontScale, weight: .semibold + ) + chevronView.image = NSImage( + systemSymbolName: "chevron.down", + accessibilityDescription: nil + )?.withSymbolConfiguration(chevronConfig) + chevronView.contentTintColor = .tertiaryLabelColor + + // Update coordinator data + context.coordinator.selectedModel = selectedModel + context.coordinator.copilotModels = copilotModels + context.coordinator.byokModels = byokModels + context.coordinator.isBYOKFFEnabled = isBYOKFFEnabled + context.coordinator.currentCache = currentCache + context.coordinator.fontScale = fontScale + + // Hover background + let isHovered = context.coordinator.isHovered + button.layer?.backgroundColor = isHovered + ? NSColor.gray.withAlphaComponent(0.1).cgColor + : NSColor.clear.cgColor + button.layer?.cornerRadius = 5 * fontScale + button.layer?.cornerCurve = .continuous + + // Ideal width based on text (allows shrinking when parent is tight) + let textWidth = labelWidth(label: label) + context.coordinator.widthConstraint?.constant = textWidth + if context.coordinator.widthConstraint == nil { + let wc = nsView.widthAnchor.constraint(lessThanOrEqualToConstant: textWidth) + wc.priority = .defaultHigh + wc.isActive = true + context.coordinator.widthConstraint = wc + } + + // Report ideal width so SwiftUI can size us properly + if let container = nsView as? ModelPickerContainerView { + container.fontScale = fontScale + container.idealWidth = textWidth + container.invalidateIntrinsicContentSize() + } + } + + func makeCoordinator() -> Coordinator { + Coordinator( + selectedModel: selectedModel, + copilotModels: copilotModels, + byokModels: byokModels, + isBYOKFFEnabled: isBYOKFFEnabled, + currentCache: currentCache, + fontScale: fontScale + ) + } + + private var selectedModelLabel: String { + let name = selectedModel?.displayName ?? selectedModel?.modelName ?? "" + if selectedModel?.degradationReason != nil { + return "\u{26A0} \(name)" + } + return name + } + + private func labelWidth(label: String) -> CGFloat { + let font = NSFont.systemFont(ofSize: 13 * fontScale) + let attrs: [NSAttributedString.Key: Any] = [.font: font] + let textWidth = ceil((label as NSString).size(withAttributes: attrs).width) + // text + left padding(6) + right padding(6) + chevron(8) + stack spacing(2) + text field internal margin(6) + return textWidth + 28 * fontScale + } + + // MARK: - Coordinator + + class Coordinator: NSObject { + var selectedModel: LLMModel? + var copilotModels: [LLMModel] + var byokModels: [LLMModel] + var isBYOKFFEnabled: Bool + var currentCache: ScopeCache + var fontScale: Double + + var button: NSButton? + var titleLabel: NSTextField? + var chevronView: NSImageView? + var trackingArea: NSTrackingArea? + var widthConstraint: NSLayoutConstraint? + var isHovered = false + + init( + selectedModel: LLMModel?, + copilotModels: [LLMModel], + byokModels: [LLMModel], + isBYOKFFEnabled: Bool, + currentCache: ScopeCache, + fontScale: Double + ) { + self.selectedModel = selectedModel + self.copilotModels = copilotModels + self.byokModels = byokModels + self.isBYOKFFEnabled = isBYOKFFEnabled + self.currentCache = currentCache + self.fontScale = fontScale + } + + @objc func buttonClicked(_ sender: NSButton) { + let menuBuilder = ModelPickerMenu( + selectedModel: selectedModel, + copilotModels: copilotModels, + byokModels: byokModels, + isBYOKFFEnabled: isBYOKFFEnabled, + currentCache: currentCache, + fontScale: fontScale + ) + menuBuilder.showMenu(relativeTo: sender) + } + + @objc(mouseEntered:) func mouseEntered(with event: NSEvent) { + isHovered = true + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.15 + button?.animator().layer?.backgroundColor = NSColor.gray + .withAlphaComponent(0.1).cgColor + } + NSCursor.pointingHand.push() + } + + @objc(mouseExited:) func mouseExited(with event: NSEvent) { + isHovered = false + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.15 + button?.animator().layer?.backgroundColor = NSColor.clear.cgColor + } + NSCursor.pop() + } + } +} + +// MARK: - Container view that constrains intrinsic height + +private class ModelPickerContainerView: NSView { + var fontScale: Double + var idealWidth: CGFloat = NSView.noIntrinsicMetric + + init(fontScale: Double) { + self.fontScale = fontScale + super.init(frame: .zero) + setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + setContentHuggingPriority(.defaultHigh, for: .horizontal) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var intrinsicContentSize: NSSize { + let height = 20 * fontScale + return NSSize(width: idealWidth, height: height) + } +} diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ModelPickerDetailPanel.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ModelPickerDetailPanel.swift new file mode 100644 index 00000000..7ea03f64 --- /dev/null +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ModelPickerDetailPanel.swift @@ -0,0 +1,256 @@ +import AppKit + +// MARK: - Floating Detail Panel (shown on menu item hover) + +class ModelPickerDetailPanel: NSPanel { + static let shared = ModelPickerDetailPanel() + + private let contentLabel = NSTextField(wrappingLabelWithString: "") + private let nameLabel = NSTextField(labelWithString: "") + private let separatorView = NSBox() + private let containerView = NSView() + private var hideTimer: Timer? + + private var containerConstraints: [NSLayoutConstraint] = [] + private var currentFontScale: CGFloat = 1.0 + + private init() { + super.init( + contentRect: NSRect(x: 0, y: 0, width: 260, height: 100), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: true + ) + self.isFloatingPanel = true + self.level = .popUpMenu + 1 + self.isOpaque = false + self.backgroundColor = .clear + self.hidesOnDeactivate = false + self.hasShadow = true + self.isMovable = false + self.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + setupContent() + } + + private func setupContent() { + let visual = NSVisualEffectView() + visual.material = .popover + visual.state = .active + visual.wantsLayer = true + visual.layer?.cornerRadius = 8 + visual.layer?.masksToBounds = true + visual.translatesAutoresizingMaskIntoConstraints = false + + containerView.translatesAutoresizingMaskIntoConstraints = false + + nameLabel.textColor = .labelColor + nameLabel.isEditable = false + nameLabel.isBordered = false + nameLabel.backgroundColor = .clear + nameLabel.drawsBackground = false + nameLabel.translatesAutoresizingMaskIntoConstraints = false + nameLabel.lineBreakMode = .byTruncatingTail + + separatorView.boxType = .separator + separatorView.translatesAutoresizingMaskIntoConstraints = false + + contentLabel.isEditable = false + contentLabel.isBordered = false + contentLabel.backgroundColor = .clear + contentLabel.drawsBackground = false + contentLabel.textColor = .secondaryLabelColor + contentLabel.usesSingleLineMode = false + contentLabel.maximumNumberOfLines = 0 + contentLabel.translatesAutoresizingMaskIntoConstraints = false + + containerView.addSubview(nameLabel) + containerView.addSubview(separatorView) + containerView.addSubview(contentLabel) + + visual.addSubview(containerView) + self.contentView = visual + + // Static constraints that don't depend on font scale + NSLayoutConstraint.activate([ + nameLabel.topAnchor.constraint(equalTo: containerView.topAnchor), + nameLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + nameLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + + separatorView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + separatorView.trailingAnchor.constraint( + equalTo: containerView.trailingAnchor + ), + + contentLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + contentLabel.trailingAnchor.constraint( + equalTo: containerView.trailingAnchor + ), + contentLabel.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + ]) + + applyScaledConstraints(to: visual, fontScale: 1.0) + } + + private func applyScaledConstraints( + to visual: NSView, + fontScale: CGFloat + ) { + NSLayoutConstraint.deactivate(containerConstraints) + + let padding: CGFloat = 10 * fontScale + let horizontalPadding: CGFloat = 12 * fontScale + let spacing: CGFloat = 6 * fontScale + + containerConstraints = [ + containerView.topAnchor.constraint( + equalTo: visual.topAnchor, constant: padding + ), + containerView.leadingAnchor.constraint( + equalTo: visual.leadingAnchor, constant: horizontalPadding + ), + containerView.trailingAnchor.constraint( + equalTo: visual.trailingAnchor, constant: -horizontalPadding + ), + containerView.bottomAnchor.constraint( + equalTo: visual.bottomAnchor, constant: -padding + ), + separatorView.topAnchor.constraint( + equalTo: nameLabel.bottomAnchor, constant: spacing + ), + contentLabel.topAnchor.constraint( + equalTo: separatorView.bottomAnchor, constant: spacing + ), + ] + + NSLayoutConstraint.activate(containerConstraints) + + nameLabel.font = NSFont.systemFont( + ofSize: 13 * fontScale, weight: .semibold + ) + contentLabel.font = NSFont.systemFont(ofSize: 12 * fontScale) + contentLabel.preferredMaxLayoutWidth = 236 * fontScale + + visual.layer?.cornerRadius = 8 * fontScale + + currentFontScale = fontScale + } + + func show( + for model: LLMModel, + nearRect: NSRect, + preferRight: Bool = true, + fontScale: CGFloat = 1.0 + ) { + hideTimer?.invalidate() + hideTimer = nil + + if let visual = self.contentView { + applyScaledConstraints(to: visual, fontScale: fontScale) + } + + let displayName = model.displayName ?? model.modelName + nameLabel.stringValue = displayName + + var details: [String] = [] + + // Provider + if let provider = model.providerName, !provider.isEmpty { + details.append("Provider: \(provider)") + } + + // Billing + if let billing = model.billing { + if billing.multiplier == 0 { + details.append("Cost: Included") + } else { + let formatted = billing.multiplier + .truncatingRemainder(dividingBy: 1) == 0 + ? String(format: "%.0f", billing.multiplier) + : String(format: "%.2f", billing.multiplier) + details.append("Cost: \(formatted)x premium") + } + } + + // Vision support + if model.supportVision { + details.append("Supports: Vision") + } + + // Degradation + if let reason = model.degradationReason { + details.append("\n\u{26A0} \(reason)") + } + + // Auto model description + if model.isAutoModel { + details = [ + "Automatically selects the best model for your request based on capacity and performance.", + "\nCost may vary based on the selected model.", + ] + } + + contentLabel.stringValue = details.joined(separator: "\n") + + // Size to fit content + let fittingSize = containerView.fittingSize + let panelWidth: CGFloat = 260 * fontScale + let panelHeight = fittingSize.height + 20 * fontScale + + let gap: CGFloat = 4 * fontScale + var origin: NSPoint + if preferRight { + origin = NSPoint( + x: nearRect.maxX + gap, y: nearRect.midY - panelHeight / 2 + ) + } else { + origin = NSPoint( + x: nearRect.minX - panelWidth - gap, + y: nearRect.midY - panelHeight / 2 + ) + } + + // Find the screen that contains the menu item + let menuScreen = NSScreen.screens.first(where: { + $0.frame.contains(nearRect.origin) + }) ?? NSScreen.main + + // Ensure the panel stays fully visible on that screen + if let screen = menuScreen { + let screenFrame = screen.visibleFrame + if origin.x + panelWidth > screenFrame.maxX { + origin.x = nearRect.minX - panelWidth - gap + } + if origin.x < screenFrame.minX { + origin.x = nearRect.maxX + gap + } + // Clamp horizontally as last resort + origin.x = max(origin.x, screenFrame.minX) + origin.x = min(origin.x, screenFrame.maxX - panelWidth) + // Clamp vertically + origin.y = max(origin.y, screenFrame.minY) + origin.y = min(origin.y, screenFrame.maxY - panelHeight) + } + + setContentSize(NSSize(width: panelWidth, height: panelHeight)) + setFrameOrigin(origin) + orderFront(nil) + } + + func scheduleHide() { + hideTimer?.invalidate() + hideTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { [weak self] _ in + self?.orderOut(nil) + } + } + + func cancelHide() { + hideTimer?.invalidate() + hideTimer = nil + } + + override func close() { + hideTimer?.invalidate() + hideTimer = nil + super.close() + } +} diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ModelPickerMenu.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ModelPickerMenu.swift new file mode 100644 index 00000000..a11f8b6f --- /dev/null +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ModelPickerMenu.swift @@ -0,0 +1,418 @@ +import AppKit +import HostAppActivator +import Persist + +// MARK: - Search Field View for Menu + +private class ModelSearchFieldView: NSView, NSSearchFieldDelegate { + let searchField = NSSearchField() + var onSearchTextChanged: ((String) -> Void)? + weak var parentMenu: NSMenu? + + init(fontScale: Double, width: CGFloat) { + let height = 30 * fontScale + super.init(frame: NSRect(x: 0, y: 0, width: width, height: height + 8 * fontScale)) + + searchField.placeholderString = "Search models..." + searchField.font = NSFont.systemFont(ofSize: 12 * fontScale) + searchField.translatesAutoresizingMaskIntoConstraints = false + searchField.focusRingType = .none + searchField.delegate = self + addSubview(searchField) + + NSLayoutConstraint.activate([ + searchField.leadingAnchor.constraint( + equalTo: leadingAnchor, constant: 8 * fontScale + ), + searchField.trailingAnchor.constraint( + equalTo: trailingAnchor, constant: -8 * fontScale + ), + searchField.centerYAnchor.constraint(equalTo: centerYAnchor), + searchField.heightAnchor.constraint(equalToConstant: height), + ]) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func controlTextDidChange(_ obj: Notification) { + guard let field = obj.object as? NSSearchField else { return } + onSearchTextChanged?(field.stringValue) + } + + /// Intercept Return / Enter in the search field to select the highlighted + /// menu item. NSMenu doesn't do this automatically for custom-view items. + func control( + _ control: NSControl, + textView _: NSTextView, + doCommandBy commandSelector: Selector + ) -> Bool { + if commandSelector == #selector(NSResponder.insertNewline(_:)) { + if let menu = parentMenu, + let highlightedItem = menu.highlightedItem, + let menuItemView = highlightedItem.view as? ModelPickerMenuItem + { + menuItemView.performSelect() + return true + } + } + return false + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if window != nil { + DispatchQueue.main.async { [weak self] in + self?.searchField.becomeFirstResponder() + } + } + } +} + +// MARK: - Custom Menu (allows key events to reach search field) + +private class ModelPickerNSMenu: NSMenu { + weak var searchField: NSSearchField? + + override func performKeyEquivalent(with event: NSEvent) -> Bool { + guard event.type == .keyDown else { + return super.performKeyEquivalent(with: event) + } + + // Return / Enter: NSMenu won't fire the action for items with custom + // views, so we find the currently highlighted ModelPickerMenuItem and + // invoke its selection callback directly. + let confirmKeyCodes: Set = [ + 36, // return + 76, // enter (numpad) + ] + if confirmKeyCodes.contains(event.keyCode) { + if let highlightedItem = highlightedItem, + let menuItemView = highlightedItem.view as? ModelPickerMenuItem + { + menuItemView.performSelect() + return true + } + return super.performKeyEquivalent(with: event) + } + + // Forward printable character input and delete keys to the search + // field. Navigation keys (arrows, Escape, Space, Tab) fall through + // to super so NSMenu handles them normally. + if let searchField = searchField, + Self.shouldForwardToSearchField(event) + { + if let window = searchField.window { + window.makeFirstResponder(searchField) + searchField.currentEditor()?.keyDown(with: event) + return true + } + } + return super.performKeyEquivalent(with: event) + } + + /// Returns `true` for key events that should be forwarded to the search + /// field: printable characters and delete/backspace. Returns `false` for + /// navigation and control keys so NSMenu can handle them. + private static func shouldForwardToSearchField(_ event: NSEvent) -> Bool { + // Always allow delete / forward-delete so the user can edit the query + let deleteKeyCodes: Set = [ + 51, // delete (backspace) + 117, // forward delete + ] + if deleteKeyCodes.contains(event.keyCode) { + return true + } + + // Reject keys that NSMenu uses for navigation / activation + let navigationKeyCodes: Set = [ + 123, // left arrow + 124, // right arrow + 125, // down arrow + 126, // up arrow + 53, // escape + 49, // space + 48, // tab + ] + if navigationKeyCodes.contains(event.keyCode) { + return false + } + + // Don't forward Cmd-key shortcuts (Cmd+A, Cmd+C, etc.) + if event.modifierFlags.contains(.command) { + return false + } + + // Forward if the key produces printable characters + if let chars = event.characters, !chars.isEmpty { + return true + } + + return false + } +} + +// MARK: - Model Picker Menu Builder + +struct ModelPickerMenu { + let selectedModel: LLMModel? + let copilotModels: [LLMModel] + let byokModels: [LLMModel] + let isBYOKFFEnabled: Bool + let currentCache: ScopeCache + let fontScale: Double + + private let detailPanel = ModelPickerDetailPanel.shared + + func showMenu(relativeTo button: NSButton) { + let menu = createMenu(allCopilotModels: copilotModels, allBYOKModels: byokModels) + let buttonFrame = button.frame + let menuOrigin = NSPoint(x: buttonFrame.minX, y: buttonFrame.maxY) + menu.popUp(positioning: nil, at: menuOrigin, in: button.superview) + detailPanel.orderOut(nil) + } + + private func createMenu( + allCopilotModels: [LLMModel], + allBYOKModels: [LLMModel] + ) -> NSMenu { + let menu = ModelPickerNSMenu() + menu.autoenablesItems = false + + let maxWidth = calculateMaxWidth( + copilotModels: allCopilotModels, + byokModels: allBYOKModels + ) + + // Search bar at top (sized to match content) + let searchItem = NSMenuItem() + let searchView = ModelSearchFieldView(fontScale: fontScale, width: maxWidth) + searchView.parentMenu = menu + searchItem.view = searchView + menu.addItem(searchItem) + menu.searchField = searchView.searchField + + // Separator after search + menu.addItem(.separator()) + + // Build initial menu items + rebuildMenuItems( + menu: menu, + copilotModels: allCopilotModels, + byokModels: allBYOKModels, + maxWidth: maxWidth, + searchText: "" + ) + + // Handle search + searchView.onSearchTextChanged = { [weak menu] searchText in + guard let menu = menu else { return } + self.rebuildMenuItems( + menu: menu, + copilotModels: allCopilotModels, + byokModels: allBYOKModels, + maxWidth: maxWidth, + searchText: searchText + ) + } + + return menu + } + + private func rebuildMenuItems( + menu: NSMenu, + copilotModels: [LLMModel], + byokModels: [LLMModel], + maxWidth: CGFloat, + searchText: String + ) { + // Remove all items except the search bar and separator (first 2 items) + while menu.items.count > 2 { + menu.removeItem(at: menu.items.count - 1) + } + + let query = searchText.lowercased().trimmingCharacters(in: .whitespaces) + + let filteredCopilotModels: [LLMModel] + let filteredBYOKModels: [LLMModel] + if query.isEmpty { + filteredCopilotModels = copilotModels + filteredBYOKModels = byokModels + } else { + filteredCopilotModels = copilotModels.filter { + ($0.displayName ?? $0.modelName).lowercased().contains(query) + || $0.modelFamily.lowercased().contains(query) + } + filteredBYOKModels = byokModels.filter { + ($0.displayName ?? $0.modelName).lowercased().contains(query) + || $0.modelFamily.lowercased().contains(query) + || ($0.providerName ?? "").lowercased().contains(query) + } + } + + let premiumModels = filteredCopilotModels.filter { $0.isPremiumModel } + let standardModels = filteredCopilotModels.filter { + $0.isStandardModel && !$0.isAutoModel + } + let autoModel = filteredCopilotModels.first(where: { $0.isAutoModel }) + + // Auto model + if let autoModel = autoModel { + addModelItem( + to: menu, model: autoModel, maxWidth: maxWidth + ) + } + + // Standard models section + addSection( + to: menu, title: "Standard Models", models: standardModels, + maxWidth: maxWidth + ) + + // Premium models section + addSection( + to: menu, title: "Premium Models", models: premiumModels, + maxWidth: maxWidth + ) + + // BYOK models section + if isBYOKFFEnabled { + addSection( + to: menu, title: "Other Models", models: filteredBYOKModels, + maxWidth: maxWidth + ) + + if query.isEmpty { + menu.addItem(.separator()) + let manageItem = NSMenuItem( + title: "Manage Models...", + action: #selector(ModelPickerMenuActions.manageModels), + keyEquivalent: "" + ) + manageItem.target = ModelPickerMenuActions.shared + menu.addItem(manageItem) + } + } + + if standardModels.isEmpty, premiumModels.isEmpty, autoModel == nil, + filteredBYOKModels.isEmpty + { + if query.isEmpty { + let addItem = NSMenuItem( + title: "Add Premium Models", + action: #selector(ModelPickerMenuActions.addPremiumModels), + keyEquivalent: "" + ) + addItem.target = ModelPickerMenuActions.shared + menu.addItem(addItem) + } else { + let noResults = NSMenuItem(title: "No models found", action: nil, keyEquivalent: "") + noResults.isEnabled = false + menu.addItem(noResults) + } + } + } + + private func addSection( + to menu: NSMenu, + title: String, + models: [LLMModel], + maxWidth: CGFloat + ) { + guard !models.isEmpty else { return } + + // Section header + menu.addItem(.separator()) + let headerItem = NSMenuItem(title: title, action: nil, keyEquivalent: "") + headerItem.isEnabled = false + let headerFont = NSFont.systemFont(ofSize: 11 * fontScale, weight: .semibold) + headerItem.attributedTitle = NSAttributedString( + string: title, + attributes: [ + .font: headerFont, + .foregroundColor: NSColor.secondaryLabelColor, + ] + ) + menu.addItem(headerItem) + + for model in models { + addModelItem(to: menu, model: model, maxWidth: maxWidth) + } + } + + private func addModelItem( + to menu: NSMenu, + model: LLMModel, + maxWidth: CGFloat + ) { + let item = NSMenuItem() + let multiplierText = currentCache + .modelMultiplierCache[model.id.appending(model.providerName ?? "")] + ?? ModelMenuItemFormatter.getMultiplierText(for: model) + + let menuItemView = ModelPickerMenuItem( + model: model, + isSelected: selectedModel == model, + multiplierText: multiplierText, + fontScale: fontScale, + fixedWidth: maxWidth, + onSelect: { + AppState.shared.setSelectedModel(model) + menu.cancelTracking() + self.detailPanel.orderOut(nil) + }, + onHover: { hoveredModel, itemRect in + self.detailPanel.show( + for: hoveredModel, + nearRect: itemRect, + fontScale: self.fontScale + ) + }, + onHoverExit: { + self.detailPanel.scheduleHide() + } + ) + item.view = menuItemView + menu.addItem(item) + } + + private func calculateMaxWidth( + copilotModels: [LLMModel], + byokModels: [LLMModel] + ) -> CGFloat { + var maxWidth: CGFloat = 0 + let allModels = isBYOKFFEnabled ? copilotModels + byokModels : copilotModels + + for model in allModels { + let multiplierText = currentCache + .modelMultiplierCache[model.id.appending(model.providerName ?? "")] + ?? ModelMenuItemFormatter.getMultiplierText(for: model) + let width = ModelPickerMenuItem.calculateItemWidth( + model: model, + multiplierText: multiplierText, + fontScale: fontScale + ) + maxWidth = max(maxWidth, width) + } + + return maxWidth + } +} + +// MARK: - Menu Action Target + +private class ModelPickerMenuActions: NSObject { + static let shared = ModelPickerMenuActions() + + @objc func manageModels() { + try? launchHostAppBYOKSettings() + } + + @objc func addPremiumModels() { + if let url = URL(string: "https://aka.ms/github-copilot-upgrade-plan") { + NSWorkspace.shared.open(url) + } + } +} diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ModelPickerMenuItem.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ModelPickerMenuItem.swift new file mode 100644 index 00000000..a395256e --- /dev/null +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ModelPickerMenuItem.swift @@ -0,0 +1,296 @@ +import AppKit + +// MARK: - Model Menu Item View + +class ModelPickerMenuItem: NSView { + private let fontScale: Double + private let model: LLMModel + private let isSelected: Bool + private let multiplierText: String + private let onSelect: () -> Void + private let onHover: ((LLMModel, NSRect) -> Void)? + private let onHoverExit: (() -> Void)? + + private var wasHighlighted = false + + private let nameLabel = NSTextField(labelWithString: "") + private let multiplierLabel = NSTextField(labelWithString: "") + private let checkmarkImageView = NSImageView() + private let warningImageView = NSImageView() + + private struct LayoutConstants { + let fontScale: Double + + var menuHeight: CGFloat { 22 * fontScale } + var checkmarkSize: CGFloat { 13 * fontScale } + var hoverEdgeInset: CGFloat { 5 * fontScale } + var fontSize: CGFloat { 13 * fontScale } + var leadingPadding: CGFloat { 9 * fontScale } + var trailingPadding: CGFloat { 9 * fontScale } + var checkmarkToText: CGFloat { 5 * fontScale } + var nameToMultiplier: CGFloat { 8 * fontScale } + } + + private lazy var constants = LayoutConstants(fontScale: fontScale) + + init( + model: LLMModel, + isSelected: Bool, + multiplierText: String, + fontScale: Double, + fixedWidth: CGFloat, + onSelect: @escaping () -> Void, + onHover: ((LLMModel, NSRect) -> Void)? = nil, + onHoverExit: (() -> Void)? = nil + ) { + self.model = model + self.isSelected = isSelected + self.multiplierText = multiplierText + self.fontScale = fontScale + self.onSelect = onSelect + self.onHover = onHover + self.onHoverExit = onHoverExit + + let constants = LayoutConstants(fontScale: fontScale) + super.init( + frame: NSRect(x: 0, y: 0, width: fixedWidth, height: constants.menuHeight) + ) + setupView() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Highlight state (driven by NSMenu) + + private var isHighlighted: Bool { + enclosingMenuItem?.isHighlighted ?? false + } + + private func setupView() { + wantsLayer = true + layer?.masksToBounds = true + + setupCheckmark() + setupWarningIcon() + setupLabels() + } + + private func setupCheckmark() { + let config = NSImage.SymbolConfiguration( + pointSize: constants.checkmarkSize, + weight: .medium + ) + checkmarkImageView.image = NSImage( + systemSymbolName: "checkmark", + accessibilityDescription: nil + )?.withSymbolConfiguration(config) + checkmarkImageView.contentTintColor = .labelColor + checkmarkImageView.translatesAutoresizingMaskIntoConstraints = false + checkmarkImageView.isHidden = !isSelected || model.degradationReason != nil + addSubview(checkmarkImageView) + + NSLayoutConstraint.activate([ + checkmarkImageView.leadingAnchor.constraint( + equalTo: leadingAnchor, constant: constants.leadingPadding + ), + checkmarkImageView.centerYAnchor.constraint(equalTo: centerYAnchor), + checkmarkImageView.widthAnchor.constraint( + equalToConstant: constants.checkmarkSize + ), + checkmarkImageView.heightAnchor.constraint( + equalToConstant: constants.checkmarkSize + ), + ]) + } + + private func setupWarningIcon() { + guard model.degradationReason != nil else { return } + + let config = NSImage.SymbolConfiguration( + pointSize: constants.checkmarkSize, + weight: .medium + ) + warningImageView.image = NSImage( + systemSymbolName: "exclamationmark.triangle", + accessibilityDescription: "Degraded" + )?.withSymbolConfiguration(config) + warningImageView.contentTintColor = .labelColor + warningImageView.translatesAutoresizingMaskIntoConstraints = false + warningImageView.isHidden = false + addSubview(warningImageView) + + NSLayoutConstraint.activate([ + warningImageView.leadingAnchor.constraint( + equalTo: leadingAnchor, constant: constants.leadingPadding + ), + warningImageView.centerYAnchor.constraint(equalTo: centerYAnchor), + warningImageView.widthAnchor.constraint( + equalToConstant: constants.checkmarkSize + ), + warningImageView.heightAnchor.constraint( + equalToConstant: constants.checkmarkSize + ), + ]) + } + + private func setupLabels() { + let displayName = model.displayName ?? model.modelName + + // Name label β€” left-aligned, truncates tail, fills remaining space + nameLabel.stringValue = displayName + nameLabel.font = NSFont.systemFont(ofSize: constants.fontSize, weight: .regular) + nameLabel.textColor = .labelColor + nameLabel.isEditable = false + nameLabel.isBordered = false + nameLabel.backgroundColor = .clear + nameLabel.drawsBackground = false + nameLabel.lineBreakMode = .byTruncatingTail + nameLabel.translatesAutoresizingMaskIntoConstraints = false + nameLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + nameLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + addSubview(nameLabel) + + // Multiplier label β€” right-aligned, never truncates + multiplierLabel.stringValue = multiplierText + multiplierLabel.font = NSFont.systemFont( + ofSize: constants.fontSize, weight: .regular + ) + multiplierLabel.textColor = .secondaryLabelColor + multiplierLabel.isEditable = false + multiplierLabel.isBordered = false + multiplierLabel.backgroundColor = .clear + multiplierLabel.drawsBackground = false + multiplierLabel.alignment = .right + multiplierLabel.translatesAutoresizingMaskIntoConstraints = false + multiplierLabel.setContentHuggingPriority(.required, for: .horizontal) + multiplierLabel.setContentCompressionResistancePriority( + .required, for: .horizontal + ) + multiplierLabel.isHidden = multiplierText.isEmpty + addSubview(multiplierLabel) + + let textLeading = checkmarkImageView.trailingAnchor + + NSLayoutConstraint.activate([ + nameLabel.leadingAnchor.constraint( + equalTo: textLeading, constant: constants.checkmarkToText + ), + nameLabel.centerYAnchor.constraint(equalTo: centerYAnchor), + + multiplierLabel.trailingAnchor.constraint( + equalTo: trailingAnchor, constant: -constants.trailingPadding + ), + multiplierLabel.centerYAnchor.constraint(equalTo: centerYAnchor), + + nameLabel.trailingAnchor.constraint( + lessThanOrEqualTo: multiplierLabel.leadingAnchor, + constant: -constants.nameToMultiplier + ), + ]) + } + + // MARK: - Mouse handling + + override func mouseUp(with _: NSEvent) { + onSelect() + } + + // MARK: - Keyboard selection + + /// Called by the menu's `performKeyEquivalent` when Return/Enter is pressed + /// while this item is highlighted. Custom-view menu items don't receive + /// the default NSMenu action, so the menu triggers selection explicitly. + func performSelect() { + onSelect() + } + + override var acceptsFirstResponder: Bool { true } + + override func keyDown(with event: NSEvent) { + let confirmKeyCodes: Set = [ + 36, // return + 76, // enter (numpad) + ] + if confirmKeyCodes.contains(event.keyCode) { + onSelect() + } else { + super.keyDown(with: event) + } + } + + // MARK: - Drawing (highlight driven by NSMenu) + + private func updateColors() { + let highlighted = isHighlighted + if highlighted { + nameLabel.textColor = .white + multiplierLabel.textColor = .white.withAlphaComponent(0.8) + checkmarkImageView.contentTintColor = .white + warningImageView.contentTintColor = .white + } else { + nameLabel.textColor = .labelColor + multiplierLabel.textColor = .secondaryLabelColor + checkmarkImageView.contentTintColor = .labelColor + warningImageView.contentTintColor = .labelColor + } + } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + + let highlighted = isHighlighted + + // Trigger detail panel on highlight change + if highlighted != wasHighlighted { + wasHighlighted = highlighted + if highlighted { + if let onHover = onHover { + let screenRect = + window?.convertToScreen(convert(bounds, to: nil)) ?? .zero + onHover(model, screenRect) + } + } else { + onHoverExit?() + } + } + + updateColors() + + if highlighted { + ModelMenuItemFormatter.drawMenuItemHighlight( + in: frame, + fontScale: fontScale, + hoverEdgeInset: constants.hoverEdgeInset + ) + } + } + + // MARK: - Width Calculation + + static func calculateItemWidth( + model: LLMModel, + multiplierText: String, + fontScale: Double + ) -> CGFloat { + let constants = LayoutConstants(fontScale: fontScale) + let font = NSFont.systemFont(ofSize: constants.fontSize, weight: .regular) + let attrs: [NSAttributedString.Key: Any] = [.font: font] + let displayName = model.displayName ?? model.modelName + let nameWidth = (displayName as NSString).size(withAttributes: attrs).width + + var width = constants.leadingPadding + constants.checkmarkSize + + constants.checkmarkToText + ceil(nameWidth) + constants.trailingPadding + + if !multiplierText.isEmpty { + let multWidth = ceil( + (multiplierText as NSString).size(withAttributes: attrs).width + ) + width += constants.nameToMultiplier + multWidth + } + + return width + } +} diff --git a/Core/Sources/ConversationTab/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/TerminalViews/RunInTerminalToolView.swift b/Core/Sources/ConversationTab/TerminalViews/RunInTerminalToolView.swift index e2182eda..7f6725a5 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 @@ -121,10 +126,12 @@ struct RunInTerminalToolView: View { .scaledFrame(width: 16, height: 16) Text(command!) + .lineLimit(nil) .textSelection(.enabled) .scaledFont(size: chatFontSize, design: .monospaced) .scaledPadding(8) .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) .foregroundStyle(codeForegroundColor) .background(codeBackgroundColor) .clipShape(RoundedRectangle(cornerRadius: 6)) @@ -141,7 +148,7 @@ 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) @@ -153,14 +160,28 @@ struct RunInTerminalToolView: View { Text("Skip") .scaledFont(.body) } - - Button(action: { - chat.send(.toolCallAccepted(tool.id)) - }) { - Text("Allow") - .scaledFont(.body) + + if 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()) } - .buttonStyle(BorderedProminentButtonStyle()) } .frame(maxWidth: .infinity, alignment: .leading) .scaledPadding(.top, 4) @@ -168,4 +189,171 @@ struct RunInTerminalToolView: View { } } } + + 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/Views/BotMessage.swift b/Core/Sources/ConversationTab/Views/BotMessage.swift index d951f589..fcc5ad9a 100644 --- a/Core/Sources/ConversationTab/Views/BotMessage.swift +++ b/Core/Sources/ConversationTab/Views/BotMessage.swift @@ -7,6 +7,7 @@ import SwiftUI import ConversationServiceProvider import ChatTab import ChatAPIService +import HostAppActivator struct BotMessage: View { var r: Double { messageBubbleCornerRadius } @@ -27,43 +28,12 @@ struct BotMessage: View { @Environment(\.colorScheme) var colorScheme @AppStorage(\.chatFontSize) var chatFontSize - @State var isReferencesPresented = false @State var isHovering = 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)) - } - } - } - } - 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 { @@ -76,63 +46,28 @@ struct BotMessage: View { return title } - var referenceIcon: some View { - Group { - if !isReferencesPresented { - HStack(alignment: .center, spacing: 0) { - Image(systemName: "chevron.right") - } - .scaledPadding(.leading, 4) - .scaledPadding(.trailing, 3) - .scaledPadding(.vertical, 1.5) - } else { - HStack(alignment: .center, spacing: 0) { - Image(systemName: "chevron.down") - } - .scaledPadding(.top, 4) - .scaledPadding(.bottom, 3) - .scaledPadding(.horizontal, 1.5) - - } - } - .scaledFont(size: chatFontSize - 1, weight: .medium) - .scaledFrame(width: 16, height: 16, alignment: .center) - } - var body: some View { + 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) { - VStack(alignment: .leading, spacing: 8) { - Button(action: { - isReferencesPresented.toggle() - }, label: { - HStack(spacing: 4) { - referenceIcon - - Text(MakeReferenceTitle(references: references)) - .scaledFont(size: chatFontSize - 1) + 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)) } - .foregroundStyle(.secondary) - }) - .buttonStyle(.plain) - .padding(.vertical, 4) - .padding(.trailing, 4) - .background { - RoundedRectangle(cornerRadius: r - 4) - .fill(isReferencesHovered ? Color.gray.opacity(0.2) : Color.clear) - } - .accessibilityValue(isReferencesPresented ? "Collapse" : "Expand") - - if isReferencesPresented { - ReferenceList(references: references, chat: chat) - .background( - RoundedRectangle(cornerRadius: 5) - .stroke(Color.gray, lineWidth: 0.2) - ) - } - } - .onHover { - isReferencesHovered = $0 - } + }, + fileHelpTexts: fileHelpTexts + ) Spacer() } @@ -140,103 +75,144 @@ struct BotMessage: View { } var body: some View { - HStack { - VStack(alignment: .leading, spacing: 8) { - 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) + + // 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 !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 { - Group{ - ThemedMarkdownText(text: text, chat: chat) + + 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() } - .scaledPadding(.leading, 2) - .scaledPadding(.vertical, 4) - } - - if let codeReviewRound = codeReviewRound { - CodeReviewMainView( - store: chat, round: codeReviewRound - ) - .frame(maxWidth: .infinity) - } - 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, + isSummarizingConversation: chat.isSummarizingConversation + ) + .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) } - .scaledPadding(.vertical, 4) } - - HStack { - if shouldShowTurnStatus() { - TurnStatusView(message: message) + .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) - Spacer() + Divider() - ResponseToolBar(id: id, chat: chat, text: text) - .conditionalFontWeight(.medium) - .opacity(shouldShowToolBar() ? 1 : 0) - .scaledPadding(.trailing, -20) - } - } - .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)) + Button("Delete") { + chat.send(.deleteMessageButtonTapped(id)) + } + .scaledFont(.body) } - .scaledFont(.body) - - Divider() - - Button("Delete") { - chat.send(.deleteMessageButtonTapped(id)) + .onHover { + isHovering = $0 } - .scaledFont(.body) } - .onHover { - isHovering = $0 + } + } + + @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) + } + } + } + } } } + .scaledPadding(.vertical, 4) } + private func isSettingsActionableError(_ message: String) -> Bool { + message == HardCodedToolRoundExceedErrorMessage || + message == SSLCertificateErrorMessage + } + private func shouldShowTurnStatus() -> Bool { guard isLatestAssistantMessage() else { return false @@ -267,86 +243,18 @@ struct BotMessage: View { } } -struct ReferenceList: View { - let references: [ConversationReference] - let chat: StoreOf - - private let maxVisibleItems: Int = 6 - @State private var itemHeight: CGFloat = 16 - - @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.. + 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 index 251f4022..346981ef 100644 --- a/Core/Sources/ConversationTab/Views/ChatPanelInputArea/ChatPanelInputArea.swift +++ b/Core/Sources/ConversationTab/Views/ChatPanelInputArea/ChatPanelInputArea.swift @@ -19,22 +19,15 @@ struct ChatPanelInputArea: View { Button(action: { chat.send(.clearButtonTap) }) { - Group { - if #available(macOS 13.0, *) { - Image(systemName: "eraser.line.dashed.fill") - .scaledFont(.body) - } else { - Image(systemName: "trash.fill") - .scaledFont(.body) + Image(systemName: "eraser.line.dashed.fill") + .scaledFont(.body) + .padding(6) + .background { + Circle().fill(Color(nsColor: .controlBackgroundColor)) + } + .overlay { + Circle().stroke(Color(nsColor: .controlColor), lineWidth: 1) } - } - .padding(6) - .background { - Circle().fill(Color(nsColor: .controlBackgroundColor)) - } - .overlay { - Circle().stroke(Color(nsColor: .controlColor), lineWidth: 1) - } } .buttonStyle(.plain) } diff --git a/Core/Sources/ConversationTab/Views/ChatPanelInputArea/ContextSizeButton.swift b/Core/Sources/ConversationTab/Views/ChatPanelInputArea/ContextSizeButton.swift new file mode 100644 index 00000000..fdf3be11 --- /dev/null +++ b/Core/Sources/ConversationTab/Views/ChatPanelInputArea/ContextSizeButton.swift @@ -0,0 +1,196 @@ +import ConversationServiceProvider +import SharedUIComponents +import SwiftUI + +struct ContextSizeButton: View { + let contextSizeInfo: ContextSizeInfo + @State private var isHovering = false + @State private var showPopover = false + @State private var isClickTriggered = false + @State private var hoverTask: Task? + @State private var dismissTask: Task? + + private let ringSize: CGFloat = 11 + private let lineWidth: CGFloat = 1.5 + + var body: some View { + Button(action: { + hoverTask?.cancel() + dismissTask?.cancel() + isClickTriggered = true + showPopover = true + }) { + HStack(spacing: 4) { + DonutChart( + percentage: contextSizeInfo.utilizationPercentage, + ringColor: ringColor, + size: ringSize, + lineWidth: lineWidth + ) + + if isHovering { + Text("\(Int(contextSizeInfo.utilizationPercentage))%") + .scaledFont(size: 11, weight: .medium) + .foregroundColor(.primary) + .transition(.opacity) + } + } + .scaledPadding(.horizontal, 6) + .scaledPadding(.vertical, 4) + } + .buttonStyle(HoverButtonStyle(padding: 0)) + .accessibilityLabel("Context size") + .accessibilityValue(Text("\(Int(contextSizeInfo.utilizationPercentage)) percent of context tokens used")) + .accessibilityHint("Shows details about the current context size and token usage.") + .animation(.easeInOut(duration: 0.15), value: isHovering) + .onHover { hovering in + isHovering = hovering + hoverTask?.cancel() + if hovering { + dismissTask?.cancel() + hoverTask = Task { + try? await Task.sleep(nanoseconds: 2_000_000_000) + guard !Task.isCancelled else { return } + isClickTriggered = false + showPopover = true + } + } else if !isClickTriggered { + scheduleDismiss() + } + } + .onChange(of: showPopover) { newValue in + if !newValue { + isClickTriggered = false + } + } + .popover(isPresented: $showPopover, arrowEdge: .bottom) { + ContextSizePopover(info: contextSizeInfo) + .onHover { hovering in + if hovering { + dismissTask?.cancel() + } else if !isClickTriggered { + scheduleDismiss() + } + } + } + } + + private func scheduleDismiss() { + dismissTask?.cancel() + dismissTask = Task { + try? await Task.sleep(nanoseconds: 200_000_000) + guard !Task.isCancelled else { return } + showPopover = false + } + } + + private var ringColor: Color { + let pct = contextSizeInfo.utilizationPercentage + if pct >= 80 { return Color("WarningYellow") } + return .secondary + } +} + +private struct DonutChart: View { + let percentage: Double + let ringColor: Color + let size: CGFloat + let lineWidth: CGFloat + + var body: some View { + ZStack { + Circle() + .stroke(Color(nsColor: .quaternaryLabelColor), lineWidth: lineWidth) + + Circle() + .trim(from: 0, to: min(percentage / 100, 1.0)) + .stroke(ringColor, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) + .rotationEffect(.degrees(-90)) + } + .scaledFrame(width: size, height: size) + } +} + +private struct ContextSizePopover: View { + let info: ContextSizeInfo + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + // MARK: Context Window + VStack(alignment: .leading, spacing: 6) { + Text("Context Window") + .scaledFont(.headline) + + HStack { + Text("\(formatTokens(info.totalUsedTokens)) / \(formatTokens(info.totalTokenLimit)) tokens") + Spacer() + Text(formatPercentage(info.utilizationPercentage)) + } + .scaledFont(.callout) + + ProgressView(value: min(info.utilizationPercentage, 100), total: 100) + .tint(progressColor) + } + + // MARK: System + VStack(alignment: .leading, spacing: 4) { + Text("System") + .scaledFont(.headline) + .scaledPadding(.bottom, 4) + + tokenRow("System Instructions", tokens: info.systemPromptTokens) + tokenRow("Tool Definitions", tokens: info.toolDefinitionTokens) + } + + // MARK: User + VStack(alignment: .leading, spacing: 4) { + Text("User") + .scaledFont(.headline) + .scaledPadding(.bottom, 4) + + tokenRow("Messages", tokens: info.userMessagesTokens + info.assistantMessagesTokens) + tokenRow("Attached Files", tokens: info.attachedFilesTokens) + tokenRow("Tool Results", tokens: info.toolResultsTokens) + } + + // TODO: Depends on CLS for manual compression + } + .scaledPadding(.vertical, 20) + .scaledPadding(.horizontal, 16) + .scaledFrame(width: 240) + } + + private var progressColor: Color { + let pct = info.utilizationPercentage + if pct >= 80 { return Color(nsColor: .systemYellow) } + return .accentColor + } + + private func tokenRow(_ label: String, tokens: Int) -> some View { + HStack { + Text(label) + Spacer() + Text(percentage(for: tokens)) + } + .scaledFont(.callout) + } + + private func percentage(for tokens: Int) -> String { + guard info.totalTokenLimit > 0 else { return "0%" } + let pct = Double(tokens) / Double(info.totalTokenLimit) * 100 + return formatPercentage(pct) + } + + private func formatPercentage(_ pct: Double) -> String { + if pct == 0 { return "0%" } + return String(format: "%.1f%%", pct) + } + + private func formatTokens(_ count: Int) -> String { + if count >= 1000 { + let k = Double(count) / 1000.0 + return String(format: "%.1fK", k) + } + return "\(count)" + } +} diff --git a/Core/Sources/ConversationTab/Views/ChatPanelInputArea/InputAreaTextEditor.swift b/Core/Sources/ConversationTab/Views/ChatPanelInputArea/InputAreaTextEditor.swift index c9addc8b..0659dbf8 100644 --- a/Core/Sources/ConversationTab/Views/ChatPanelInputArea/InputAreaTextEditor.swift +++ b/Core/Sources/ConversationTab/Views/ChatPanelInputArea/InputAreaTextEditor.swift @@ -35,6 +35,7 @@ struct InputAreaTextEditor: View { ) @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 @@ -78,13 +79,17 @@ struct InputAreaTextEditor: View { return false } - - var typedMessage: String { - chat.state.getChatContext(of: editorMode).typedMessage + + 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 @@ -165,25 +170,43 @@ struct InputAreaTextEditor: View { .padding(.top, 4) HStack(spacing: 0) { - ModelPicker() - + ModeAndModelPicker(projectRootURL: projectRootURL, selectedAgent: $chat.selectedAgent) + Spacer() - - if chat.editorMode.isDefault { + + if let contextSizeInfo = chat.contextSizeInfo { + ContextSizeButton(contextSizeInfo: contextSizeInfo) + .padding(.trailing, 4) + } + + if chat.editorMode.isDefault && !isRequestingConversation { codeReviewButton - .buttonStyle(HoverButtonStyle(padding: 0)) - .disabled(isRequestingConversation) + .buttonStyle(HoverButtonStyle(padding: 0, hoverColor: .clear)) + .padding(.trailing, 4) } - + ZStack { sendButton - .opacity(isRequestingConversation ? 0 : 1) - + .opacity(isRequestingConversation || isRequestingCodeReview ? 0 : 1) + .foregroundColor( + typedMessage.isEmpty ? Color(nsColor: .tertiaryLabelColor) : Color( + "IconStrokeColor" + ) + ) + .disabled(typedMessage.isEmpty) + stopButton - .opacity(isRequestingConversation ? 1 : 0) + .opacity(isRequestingConversation || isRequestingCodeReview ? 1 : 0) + .foregroundColor(Color("IconStrokeColor")) } - .buttonStyle(HoverButtonStyle(padding: 0)) - .disabled(isRequestingCodeReview) + .buttonStyle( + HoverButtonStyle( + padding: 0, + hoverColor: Color(nsColor: .quaternaryLabelColor), + backgroundColor: Color(nsColor: .quinaryLabel), + cornerRadius: .infinity + ) + ) } .padding(8) .padding(.top, -4) @@ -286,9 +309,12 @@ struct InputAreaTextEditor: View { Button(action: { submitChatMessage() }) { - Image(systemName: "paperplane.fill") - .scaledFont(.body) - .padding(4) + 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") @@ -298,10 +324,12 @@ struct InputAreaTextEditor: View { Button(action: { chat.send(.stopRespondingButtonTapped) }) { - Image(systemName: "stop.circle") - .scaledFont(.body) - .padding(4) + Image(systemName: "stop.fill") + .scaledFont(size: 12, weight: .medium) + .padding(8) } + .keyboardShortcut(KeyEquivalent.escape, modifiers: []) + .help("Stop") } private var isFreeUser: Bool { @@ -331,31 +359,28 @@ struct InputAreaTextEditor: View { 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 + Menu { + Button(action: { + chat.send(.codeReview(.request(.index))) + }) { + Text("Review Staged Changes") } - .scaledFont(.body) - .opacity(isRequestingCodeReview ? 0 : 1) - .help("Code Review") + + Button(action: { + chat.send(.codeReview(.request(.workingTree))) + }) { + Text("Review Unstaged Changes") + } + } label: { + codeReviewIcon + .foregroundColor(isCCRHovering ? .primary : Color("IconStrokeColor")) } - .buttonStyle(HoverButtonStyle(padding: 0)) + .scaledFont(.body) + .onHover { hovering in + isCCRHovering = hovering + } + .opacity(isRequestingCodeReview ? 0 : 1) + .help("Code Review") } else { codeReviewIcon .foregroundColor(Color(nsColor: .tertiaryLabelColor)) @@ -460,7 +485,7 @@ struct InputAreaTextEditor: View { } }() - HStack(spacing: 0) { + HStack(alignment: .center, spacing: 0) { makeContextFileNameView(url: ref.url, isCurrentEditor: true, selection: ref.selection) Toggle("", isOn: $isCurrentEditorContextEnabled) @@ -500,7 +525,6 @@ struct InputAreaTextEditor: View { selection: LSPRange? = nil ) -> some View { drawFileIcon(url, isDirectory: isDirectory) - .resizable() .scaledToFit() .scaledFrame(width: 16, height: 16) .foregroundColor(.primary.opacity(0.85)) diff --git a/Core/Sources/ConversationTab/Views/CodeReviewRound/FileSelectionSection.swift b/Core/Sources/ConversationTab/Views/CodeReviewRound/FileSelectionSection.swift index 9686afd3..bc044fa7 100644 --- a/Core/Sources/ConversationTab/Views/CodeReviewRound/FileSelectionSection.swift +++ b/Core/Sources/ConversationTab/Views/CodeReviewRound/FileSelectionSection.swift @@ -255,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/ConversationAgentProgressView.swift b/Core/Sources/ConversationTab/Views/ConversationAgentProgressView.swift deleted file mode 100644 index 39c8ccc4..00000000 --- a/Core/Sources/ConversationTab/Views/ConversationAgentProgressView.swift +++ /dev/null @@ -1,229 +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: 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) - } - } - } - } - .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(action: { - chat.send(.toolCallCancelled(tool.id)) - }) { - Text("Skip") - .scaledFont(.body) - } - - Button(action: { - chat.send(.toolCallAccepted(tool.id)) - }) { - Text("Allow") - .scaledFont(.body) - } - .buttonStyle(BorderedProminentButtonStyle()) - - } - .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 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) - .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 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(Color.successLightGreen) - case .error: - Image(systemName: "xmark.circle") - .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) - } - - 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..fc970828 --- /dev/null +++ b/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ConversationAgentProgressView.swift @@ -0,0 +1,406 @@ +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 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) + } + + 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 + } + + private var sensitiveFileSplitButton: some View { + SplitButton( + title: "Allow", + isDisabled: false, + primaryAction: { + chat.send(.toolCallAccepted(tool.id)) + }, + menuItems: sensitiveFileMenuItems, + style: .prominent + ) + } + + 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 + } + + 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 7697e422..739b126a 100644 --- a/Core/Sources/ConversationTab/Views/ConversationProgressStepView.swift +++ b/Core/Sources/ConversationTab/Views/ConversationProgressStepView.swift @@ -46,12 +46,11 @@ struct StatusItemView: View { .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 { @@ -60,12 +59,13 @@ struct StatusItemView: View { statusIcon .scaledFrame(width: 16, height: 16) - statusTitle + Text(statusTitleText) .scaledFont(size: chatFontSize - 1) .lineLimit(1) Spacer() } + .help(statusTitleText) } } } diff --git a/Core/Sources/ConversationTab/Views/NotificationBanner.swift b/Core/Sources/ConversationTab/Views/NotificationBanner.swift index 3762c1a1..f5047793 100644 --- a/Core/Sources/ConversationTab/Views/NotificationBanner.swift +++ b/Core/Sources/ConversationTab/Views/NotificationBanner.swift @@ -20,18 +20,19 @@ 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) .scaledPadding(.vertical, 10) diff --git a/Core/Sources/ConversationTab/Views/ThemedMarkdownText.swift b/Core/Sources/ConversationTab/Views/ThemedMarkdownText.swift index f00d76e4..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) } diff --git a/Core/Sources/ConversationTab/Views/WorkingSetView.swift b/Core/Sources/ConversationTab/Views/WorkingSetView.swift index 7cb5eb73..f572454b 100644 --- a/Core/Sources/ConversationTab/Views/WorkingSetView.swift +++ b/Core/Sources/ConversationTab/Views/WorkingSetView.swift @@ -14,23 +14,26 @@ struct WorkingSetView: View { private let r: Double = 8 + @State private var isExpanded: Bool = false + var body: some View { WithPerceptionTracking { VStack(spacing: 4) { - WorkingSetHeader(chat: chat) - .scaledFrame(height: 24) + 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) + } } } } .scaledPadding(.horizontal, 5) - .scaledPadding(.top, 8) - .scaledPadding(.bottom, 10) + .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) - .scaledPadding(.horizontal, 6) - .padding(.vertical, 2) - .background(textBackgroundColor) - .cornerRadius(2) - .overlay( - RoundedRectangle(cornerRadius: 2) - .stroke(Color.white.opacity(0.07), lineWidth: 1) - ) - .scaledFrame(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) 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..a8910979 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,37 @@ 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 +88,19 @@ 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 + + Divider() + + if featureFlags.isAgentModeEnabled { + // Agent Max Tool Calling Requests + AgentMaxToolCallLoopSetting() + .padding(SettingsToggle.defaultPadding) + + Divider() } - } catch { - Logger.client.error("Failed to get copilot feature flags: \(error)") + + // Auto Compress + AgentAutoCompressSetting() } } } @@ -260,6 +281,87 @@ 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 AgentAutoCompressSetting: View { + @AppStorage(\.autoCompress) var autoCompress + + var body: some View { + SettingsToggle( + title: "Auto Compress", + subtitle: "Automatically compact the conversation history to save contect tokens.", + isOn: Binding( + get: { autoCompress }, + set: { + autoCompress = $0 + DistributedNotificationCenter + .default() + .post(name: .githubCopilotAgentAutoCompressDidChange, object: nil) + } + ) + ) + } +} + struct CopilotInstructionSetting: View { @State var isGlobalInstructionsViewOpen = false @Environment(\.toast) var toast @@ -351,8 +453,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 +486,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/DisabledLanguageList.swift b/Core/Sources/HostApp/AdvancedSettings/DisabledLanguageList.swift index d869b9ca..2ccbdd71 100644 --- a/Core/Sources/HostApp/AdvancedSettings/DisabledLanguageList.swift +++ b/Core/Sources/HostApp/AdvancedSettings/DisabledLanguageList.swift @@ -5,13 +5,8 @@ import SharedUIComponents extension List { @ViewBuilder func removeBackground() -> some View { - if #available(macOS 13.0, *) { - scrollContentBackground(.hidden) - .listRowBackground(EmptyView()) - } else { - background(Color.clear) - .listRowBackground(EmptyView()) - } + scrollContentBackground(.hidden) + .listRowBackground(EmptyView()) } } @@ -80,11 +75,7 @@ struct DisabledLanguageList: View { } } .modify { view in - if #available(macOS 13.0, *) { - view.listRowSeparator(.hidden).listSectionSeparator(.hidden) - } else { - view - } + view.listRowSeparator(.hidden).listSectionSeparator(.hidden) } } .removeBackground() diff --git a/Core/Sources/HostApp/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 bc232f54..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 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 9633b8ad..7c0b2e03 100644 --- a/Core/Sources/HostApp/SharedComponents/Badge.swift +++ b/Core/Sources/HostApp/SharedComponents/Badge.swift @@ -11,12 +11,14 @@ 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 } } @@ -26,6 +28,7 @@ struct Badge: View { let level: BadgeItem.Level let icon: String? let isSelected: Bool + let tooltip: String? init(badgeItem: BadgeItem) { text = badgeItem.text @@ -33,22 +36,25 @@ struct Badge: View { 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) { + 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 { @@ -58,13 +64,13 @@ struct Badge: View { .font(.caption2) .padding(.vertical, 1) } - if let attributedText = attributedText { + if let attributedText = attributedText, attributedText.characters.count > 0 { Text(attributedText) .fontWeight(.semibold) .font(.caption2) .lineLimit(1) .truncationMode(.middle) - } else { + } else if !text.isEmpty { Text(text) .fontWeight(.semibold) .font(.caption2) @@ -96,6 +102,20 @@ struct Badge: View { lineWidth: 1 ) ) - .help(text) + .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 69c51dd6..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 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/SplitButton.swift b/Core/Sources/HostApp/SharedComponents/SplitButton.swift deleted file mode 100644 index 3f9897fb..00000000 --- a/Core/Sources/HostApp/SharedComponents/SplitButton.swift +++ /dev/null @@ -1,116 +0,0 @@ -import SwiftUI -import AppKit - -// MARK: - SplitButton Menu Item - -public struct SplitButtonMenuItem: Identifiable { - public let id = UUID() - public let title: String - public let action: () -> Void - - public init(title: String, action: @escaping () -> Void) { - self.title = title - self.action = action - } -} - -// MARK: - SplitButton using NSComboButton - -@available(macOS 13.0, *) -public struct SplitButton: NSViewRepresentable { - let title: String - let primaryAction: () -> Void - let isDisabled: Bool - let menuItems: [SplitButtonMenuItem] - - public init( - title: String, - isDisabled: Bool = false, - primaryAction: @escaping () -> Void, - menuItems: [SplitButtonMenuItem] = [] - ) { - self.title = title - self.isDisabled = isDisabled - self.primaryAction = primaryAction - self.menuItems = menuItems - } - - public 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 - } - - public func updateNSView(_ nsView: NSComboButton, context: Context) { - nsView.title = title - nsView.isEnabled = !isDisabled - context.coordinator.updateMenu(with: menuItems) - } - - public func makeCoordinator() -> Coordinator { - Coordinator(primaryAction: primaryAction) - } - - public 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]) { - let menu = NSMenu() - menuItemActions.removeAll() - - // Add fixed menu title if there are items - if !items.isEmpty { - if #available(macOS 14.0, *) { - let headerItem = NSMenuItem.sectionHeader(title: "Install Server With") - menu.addItem(headerItem) - } else { - let headerItem = NSMenuItem() - headerItem.title = "Install Server With" - headerItem.isEnabled = false - menu.addItem(headerItem) - } - - // Add menu items - for item in items { - let menuItem = NSMenuItem( - title: item.title, - action: #selector(handleMenuItemAction(_:)), - keyEquivalent: "" - ) - menuItem.target = self - menuItem.representedObject = item.id - menuItemActions[item.id] = item.action - menu.addItem(menuItem) - } - } - - button?.menu = menu - } - } -} 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 c5bd9c45..6ece9ade 100644 --- a/Core/Sources/HostApp/ToolsConfigView.swift +++ b/Core/Sources/HostApp/ToolsConfigView.swift @@ -4,6 +4,7 @@ import ConversationServiceProvider import Foundation import GitHubCopilotService import Logger +import Persist import SharedUIComponents import SwiftUI import SystemUtils @@ -12,34 +13,38 @@ import Toast 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 isEditorPreviewEnabled = false - @State private var selectedOption = ToolType.MCP + @State private var selectedMode: ConversationMode = .defaultAgent @Environment(\.colorScheme) var colorScheme + private var isCustomAgentEnabled: Bool { + copilotPolicy.isCustomAgentEnabled + } + private static var lastSyncTimestamp: Date? = nil @State private var debounceTimer: Timer? private static let refreshDebounceInterval: TimeInterval = 1.0 // 1.0 second debounce - enum ToolType: String, CaseIterable, Identifiable { - case MCP, BuiltIn - var id: Self { self } - } - var body: some View { WithPerceptionTracking { ScrollView { - Picker("", selection: $selectedOption) { + 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(ToolType.MCP) - Text("Built-In".padded(centerTo: 24, with: "\u{2002}")).tag(ToolType.BuiltIn) + 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(ToolType.MCP) - Text("Built-In").tag(ToolType.BuiltIn) + Text("MCP").tag(ToolsSubTab.MCP) + Text("Built-In").tag(ToolsSubTab.BuiltIn) + Text("Auto-Approve").tag(ToolsSubTab.AutoApprove) } } .frame(width: 400) @@ -49,17 +54,22 @@ struct MCPConfigView: View { .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() - if isEditorPreviewEnabled && ( SystemUtils.isPrereleaseBuild || SystemUtils.isDeveloperMode ) { + if featureFlags.isEditorPreviewEnabled { MCPRegistryURLView() } - MCPToolsListView() + MCPXcodeServerInstallView() + + MCPToolsListView( + selectedMode: $selectedMode, + isCustomAgentEnabled: isCustomAgentEnabled + ) HStack { Spacer() @@ -71,18 +81,14 @@ struct MCPConfigView: View { } .onAppear { setupConfigFilePath() - Task { - await updateFeatureFlag() - // Start monitoring if feature is already enabled on initial load - if isMCPFFEnabled { - startMonitoringConfigFile() - } + if featureFlags.isMCPEnabled { + startMonitoringConfigFile() } } .onDisappear { stopMonitoringConfigFile() } - .onChange(of: isMCPFFEnabled) { newMCPFFEnabled in + .onChange(of: featureFlags.isMCPEnabled) { newMCPFFEnabled in if newMCPFFEnabled { startMonitoringConfigFile() refreshConfiguration() @@ -90,14 +96,18 @@ struct MCPConfigView: View { stopMonitoringConfigFile() } } - .onReceive(DistributedNotificationCenter.default() - .publisher(for: .gitHubCopilotFeatureFlagsDidChange)) { _ in - Task { - await updateFeatureFlag() - } + .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(.horizontal, 20) @@ -105,18 +115,6 @@ struct MCPConfigView: View { } } - private func updateFeatureFlag() async { - do { - let service = try getService() - if let featureFlags = try await service.getCopilotFeatureFlags() { - isMCPFFEnabled = featureFlags.mcp - isEditorPreviewEnabled = featureFlags.editorPreviewFeatures - } - } catch { - Logger.client.error("Failed to get copilot feature flags: \(error)") - } - } - private func setupConfigFilePath() { let fileManager = FileManager.default @@ -182,7 +180,7 @@ 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") @@ -192,9 +190,9 @@ struct MCPConfigView: View { // Check for file changes periodically while isMonitoring { - try? await Task.sleep(nanoseconds: 3_000_000_000) // Check every 1 second for better responsiveness + try? await Task.sleep(nanoseconds: 3_000_000_000) // Check every 3 second for better responsiveness - guard isMonitoring else { break } // Extra check after sleep + guard isMonitoring else { break } // Extra check after sleep let currentDate = getFileModificationDate(url: configFileURL) @@ -275,8 +273,3 @@ extension String { return String(repeating: pad, count: left) + self + String(repeating: pad, count: right) } } - -#Preview { - MCPConfigView() - .frame(width: 800, height: 600) -} 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 cc6e745d..8c3444d2 100644 --- a/Core/Sources/HostApp/ToolsSettings/CopilotMCPToolManagerObservable.swift +++ b/Core/Sources/HostApp/ToolsSettings/CopilotMCPToolManagerObservable.swift @@ -52,9 +52,7 @@ class CopilotMCPToolManagerObservable: ObservableObject { 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)]") - - AppState.shared.cleanupMCPToolsStatus(availableTools: tools) - AppState.shared.createMCPToolsStatus(tools) + self.availableMCPServerTools = tools } } diff --git a/Core/Sources/HostApp/ToolsSettings/MCPAppState.swift b/Core/Sources/HostApp/ToolsSettings/MCPAppState.swift deleted file mode 100644 index 05b6ad84..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( - availableTools.map { collection in - (collection.name, Set(collection.tools.map { $0.name })) - } - ) { first, _ in first } - - // 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 c48b89f2..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) { @@ -75,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 { diff --git a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryInstallation.swift b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryInstallation.swift index 61aa5885..d3ebbc0c 100644 --- a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryInstallation.swift +++ b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryInstallation.swift @@ -27,7 +27,7 @@ private struct RegistryType { let commandName: String func buildArguments(for package: Package) -> [String] { - let identifier = package.identifier ?? "" + let identifier = package.identifier let version = package.version ?? "" switch package.registryType { @@ -54,25 +54,59 @@ private let registryTypes: [String: RegistryType] = [ "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() - @AppStorage(\.mcpRegistryURL) var mcpRegistryURL + public static let apiVersion = "v0.1" + @AppStorage(\.mcpRegistryBaseURL) var mcpRegistryBaseURL private init() {} - public static func getServerId(from serverDetail: MCPRegistryServerDetail) -> String? { - return serverDetail.meta?.official?.id + public static func getServerName(from serverDetail: MCPRegistryServerDetail) -> String { + return serverDetail.name } - public func getRegistryURL() throws -> String { - let url = mcpRegistryURL.trimmingCharacters(in: .whitespacesAndNewlines) + public func getRegistryBaseURL() throws -> String { + let url = mcpRegistryBaseURL.trimmingCharacters(in: .whitespacesAndNewlines) guard !url.isEmpty else { throw MCPRegistryError.registryURLNotConfigured } - return url + return url.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + } + + public func getRegistryURL() throws -> String { + return try getRegistryBaseURL() + "/\(MCPRegistryService.apiVersion)/servers" } // MARK: - Installation Options @@ -94,12 +128,11 @@ public class MCPRegistryService: ObservableObject { // Add package options serverDetail.packages?.enumerated().forEach { index, package in let config = createServerConfig(for: serverDetail, package: package) - let registryDisplay = package.registryType?.registryDisplayText ?? "Unknown" - let identifier = package.identifier.map { " : \($0)" } ?? "" - + let registryDisplay = package.registryType.registryDisplayText + options.append(InstallationOption( - displayName: "\(registryDisplay)\(identifier)", - description: "Install \(package.identifier ?? "") from \(registryDisplay)", + displayName: "\(registryDisplay) : \(package.identifier)", + description: "Install \(package.identifier) from \(registryDisplay)", config: config, isDefault: index == 0 && options.isEmpty )) @@ -182,9 +215,9 @@ public class MCPRegistryService: ObservableObject { } public func createServerConfig(for serverDetail: MCPRegistryServerDetail, package: Package) -> [String: Any] { - let registryType = registryTypes[package.registryType ?? ""] - let command = package.runtimeHint ?? registryType?.commandName ?? (package.registryType ?? "unknown") - + let registryType = registryTypes[package.registryType] + let command = package.runtimeHint ?? registryType?.commandName ?? package.registryType + var config: [String: Any] = [ "type": "stdio", "command": command @@ -198,7 +231,10 @@ public class MCPRegistryService: ObservableObject { // Default arguments if no runtime arguments if package.runtimeArguments?.isEmpty != false { - args.append(contentsOf: registryType?.buildArguments(for: package) ?? [package.identifier ?? ""]) + args + .append( + contentsOf: registryType?.buildArguments(for: package) ?? [package.identifier] + ) } // Package arguments @@ -216,17 +252,24 @@ public class MCPRegistryService: ObservableObject { } private func addMetadata(to config: inout [String: Any], serverDetail: MCPRegistryServerDetail) { - var registry: [String: Any] = [:] - - if let url = try? getRegistryURL() { - registry["url"] = url - } - - if let serverId = Self.getServerId(from: serverDetail) { - registry["serverId"] = serverId - } + guard let baseURL = try? getRegistryBaseURL() else { return } - config["x-metadata"] = ["registry": registry] + 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] { @@ -234,7 +277,7 @@ public class MCPRegistryService: ObservableObject { case let .positional(positionalArg): return (positionalArg.value ?? positionalArg.valueHint).map { [$0] } ?? [] case let .named(namedArg): - return [namedArg.name ?? ""] + (namedArg.value.map { [$0] } ?? []) + return [namedArg.name] + (namedArg.value.map { [$0] } ?? []) } } @@ -261,7 +304,7 @@ public class MCPRegistryService: ObservableObject { // Save configuration let jsonData = try JSONSerialization.data(withJSONObject: config, options: [.prettyPrinted]) - try jsonData.write(to: configFileURL) + try jsonData.write(to: configFileURL, options: .atomic) // Note: UserDefaults update and notification will be handled by ToolsConfigView's file monitor // with debouncing to prevent duplicate notifications @@ -288,12 +331,16 @@ public class MCPRegistryService: ObservableObject { 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 ?? "unknown") + 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 ?? ""]) + args.append( + contentsOf: registryTypes[package.registryType]?.buildArguments(for: package) ?? [package.identifier] + ) } package.packageArguments?.forEach { args.append(contentsOf: extractArgumentValues(from: $0)) } return args.first @@ -326,35 +373,31 @@ public class MCPRegistryService: ObservableObject { } } - public func createRegistryServerKey(registryURL: String, serverId: String) -> String { - let baseURL = normalizeRegistryURL(registryURL) - return "\(baseURL)|\(serverId)" + 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 normalizeRegistryURL(_ url: String) -> String { - // Remove trailing /v0/servers, /v0.1/servers or similar version paths - var normalized = url.trimmingCharacters(in: .whitespacesAndNewlines) - if let range = normalized.range(of: "/v\\d+(\\.\\d+)?/servers$", options: .regularExpression) { - normalized = String(normalized[.. String? { - guard let serverId = Self.getServerId(from: serverDetail), - let registryURL = try? getRegistryURL() else { return nil } - return createRegistryServerKey(registryURL: registryURL, serverId: serverId) + 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 url = registry["url"] as? String, - let serverId = registry["serverId"] as? String else { return nil } - return createRegistryServerKey(registryURL: url, serverId: serverId) + 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) } } @@ -370,7 +413,7 @@ public enum MCPRegistryError: LocalizedError { public var errorDescription: String? { switch self { case .registryURLNotConfigured: - return "MCP Registry URL is not configured. Please configure the registry URL in Settings > Tools > GitHub Copilot > MCP to browse and install servers from the registry." + 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: diff --git a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryURLInputField.swift b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryURLInputField.swift index 0f2101a6..af7621e5 100644 --- a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryURLInputField.swift +++ b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryURLInputField.swift @@ -4,11 +4,11 @@ import SharedUIComponents struct MCPRegistryURLInputField: View { @Binding var urlText: String - @AppStorage(\.mcpRegistryURLHistory) private var urlHistory + @AppStorage(\.mcpRegistryBaseURLHistory) private var urlHistory @State private var showHistory: Bool = false @FocusState private var isFocused: Bool - let defaultMCPRegistryURL = "https://api.mcp.github.com/2025-09-15/v0/servers" + let defaultMCPRegistryBaseURL = "https://api.mcp.github.com" let maxURLLength: Int let isSheet: Bool let mcpRegistryEntry: MCPRegistryEntry? @@ -40,7 +40,7 @@ struct MCPRegistryURLInputField: View { HStack(spacing: 8) { if isSheet { TextFieldsContainer { - TextField("MCP Registry URL", text: $urlText) + TextField("MCP Registry Base URL", text: $urlText) .focused($isFocused) .disabled(isRegistryOnly) .onChange(of: urlText) { newValue in @@ -51,7 +51,7 @@ struct MCPRegistryURLInputField: View { } } } else { - TextField("MCP Registry URL:", text: $urlText) + TextField("MCP Registry Base URL:", text: $urlText) .textFieldStyle(.roundedBorder) .focused($isFocused) .disabled(isRegistryOnly) @@ -75,7 +75,7 @@ struct MCPRegistryURLInputField: View { Divider() Button("Reset to Default") { - urlText = defaultMCPRegistryURL + urlText = defaultMCPRegistryBaseURL onCommit?() } diff --git a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryURLSheet.swift b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryURLSheet.swift index ba7fa890..efbc922a 100644 --- a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryURLSheet.swift +++ b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryURLSheet.swift @@ -1,11 +1,12 @@ import GitHubCopilotService import SwiftUI +import SharedUIComponents struct MCPRegistryURLSheet: View { - @AppStorage(\.mcpRegistryURL) private var mcpRegistryURL - @AppStorage(\.mcpRegistryURLHistory) private var mcpRegistryURLHistory + @AppStorage(\.mcpRegistryBaseURL) private var mcpRegistryBaseURL + @AppStorage(\.mcpRegistryBaseURLHistory) private var mcpRegistryBaseURLHistory @Environment(\.dismiss) private var dismiss - @State private var originalMcpRegistryURL: String = "" + @State private var originalMcpRegistryBaseURL: String = "" @State private var isFormValid: Bool = true let mcpRegistryEntry: MCPRegistryEntry? @@ -21,14 +22,14 @@ struct MCPRegistryURLSheet: View { VStack(alignment: .center, spacing: 20) { HStack(alignment: .center) { Spacer() - Text("MCP Registry URL").font(.headline) + Text("MCP Registry Base URL").font(.headline) Spacer() AdaptiveHelpLink(action: openHelpLink) } VStack(alignment: .leading, spacing: 4) { MCPRegistryURLInputField( - urlText: $originalMcpRegistryURL, + urlText: $originalMcpRegistryBaseURL, isSheet: true, mcpRegistryEntry: mcpRegistryEntry, onValidationChange: { isValid in @@ -42,8 +43,11 @@ struct MCPRegistryURLSheet: View { Button("Cancel", role: .cancel) { dismiss() } Button("Update") { // Check if URL changed before updating - if originalMcpRegistryURL != mcpRegistryURL { - mcpRegistryURL = originalMcpRegistryURL + originalMcpRegistryBaseURL = originalMcpRegistryBaseURL + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) + if originalMcpRegistryBaseURL != mcpRegistryBaseURL { + mcpRegistryBaseURL = originalMcpRegistryBaseURL onURLUpdated?() } dismiss() @@ -62,7 +66,7 @@ struct MCPRegistryURLSheet: View { } private func loadExistingURL() { - originalMcpRegistryURL = mcpRegistryURL + originalMcpRegistryBaseURL = mcpRegistryBaseURL } private func openHelpLink() { diff --git a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryURLView.swift b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryURLView.swift index 3d6c87a3..b3cb3537 100644 --- a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryURLView.swift +++ b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryURLView.swift @@ -9,23 +9,23 @@ import ComposableArchitecture struct MCPRegistryURLView: View { @State private var isExpanded: Bool = false - @AppStorage(\.mcpRegistryURL) var mcpRegistryURL - @AppStorage(\.mcpRegistryURLHistory) private var mcpRegistryURLHistory + @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/servers" + private let mcpRegistryUrlVersion = "/v0.1/servers" var body: some View { WithPerceptionTracking { VStack(spacing: 0) { DisclosureSettingsRow( isExpanded: $isExpanded, - accessibilityLabel: { $0 ? "Collapse mcp registry URL section" : "Expand mcp registry URL section" }, - title: { Text("MCP Registry URL").font(.headline) + Text(" (Optional)") }, + 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) { @@ -47,7 +47,7 @@ struct MCPRegistryURLView: View { .conditionalFontWeight(.semibold) } .buttonStyle(.bordered) - .help("Configure your MCP Registry URL") + .help("Configure your MCP Registry Base URL") .disabled(mcpRegistry?.first?.registryAccess == .registryOnly) Button { Task{ await loadMCPServers() } } label: { @@ -80,8 +80,11 @@ struct MCPRegistryURLView: View { }, onCommit: { // Update mcpRegistryURL when user presses Enter - if tempURLText != mcpRegistryURL { - mcpRegistryURL = tempURLText + tempURLText = tempURLText + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) + if tempURLText != mcpRegistryBaseURL { + mcpRegistryBaseURL = tempURLText } } ) @@ -95,27 +98,19 @@ struct MCPRegistryURLView: View { .background(QuaternarySystemFillColor.opacity(0.75)) .transition(.opacity.combined(with: .scale(scale: 1, anchor: .top))) .onAppear { - tempURLText = mcpRegistryURL + tempURLText = mcpRegistryBaseURL } } } - .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) .onAppear { - tempURLText = mcpRegistryURL + tempURLText = mcpRegistryBaseURL Task { await getMCPRegistryAllowlist() } } .onReceive(DistributedNotificationCenter.default().publisher(for: .authStatusDidChange)) { _ in Task { await getMCPRegistryAllowlist() } } - .onChange(of: mcpRegistryURL) { newValue in + .onChange(of: mcpRegistryBaseURL) { newValue in // Update the temp text to reflect the new URL tempURLText = newValue Task { await updateGalleryWindowIfOpen() } @@ -128,8 +123,9 @@ struct MCPRegistryURLView: View { private func loadMCPServers() async { // Update mcpRegistryURL with current tempURLText before loading - if tempURLText != mcpRegistryURL { - mcpRegistryURL = tempURLText + tempURLText = tempURLText.trimmingCharacters(in: .whitespacesAndNewlines) + if tempURLText != mcpRegistryBaseURL { + mcpRegistryBaseURL = tempURLText } isLoading = true @@ -137,16 +133,16 @@ struct MCPRegistryURLView: View { do { let service = try getService() let serverList = try await service.listMCPRegistryServers( - .init(baseUrl: mcpRegistryURL, limit: 30) + .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: \(mcpRegistryURL)") + Logger.client.info("No MCP servers found at registry URL: \(mcpRegistryBaseURL)") return } // Add to history on successful load - mcpRegistryURLHistory.addToHistory(mcpRegistryURL) + mcpRegistryBaseURLHistory.addToHistory(mcpRegistryBaseURL) errorMessage = "" MCPServerGalleryWindow.open(serverList: serverList, mcpRegistryEntry: mcpRegistry?.first) @@ -186,11 +182,8 @@ struct MCPRegistryURLView: View { } if let firstRegistry = result.mcpRegistries.first { - let baseUrl = firstRegistry.url.hasSuffix("/") - ? String(firstRegistry.url.dropLast()) - : firstRegistry.url let entry = MCPRegistryEntry( - url: baseUrl + mcpRegistryUrlVersion, + url: firstRegistry.url, registryAccess: firstRegistry.registryAccess, owner: firstRegistry.owner ) @@ -199,7 +192,7 @@ struct MCPRegistryURLView: View { // If registryOnly, force the URL to be the registry URL if entry.registryAccess == .registryOnly { - mcpRegistryURL = entry.url + mcpRegistryBaseURL = entry.url tempURLText = entry.url } } diff --git a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerDetailSheet.swift b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerDetailSheet.swift index 986e9e90..b462519a 100644 --- a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerDetailSheet.swift +++ b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerDetailSheet.swift @@ -4,9 +4,9 @@ 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 = [] @@ -28,8 +28,9 @@ struct MCPServerDetailSheet: View { var id: Self { self } } - init(server: MCPRegistryServerDetail) { - self.server = server + 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)) } @@ -96,10 +97,10 @@ struct MCPServerDetailSheet: View { private var headerSection: some View { VStack(alignment: .leading, spacing: 12) { HStack(alignment: .center) { - Text(server.name) + Text(server.title ?? server.name) .font(.system(size: 18, weight: .semibold)) - if let status = server.status, status == .deprecated { + if let status = meta?.official?.status, status == .deprecated { statusBadge(status) } @@ -113,15 +114,15 @@ struct MCPServerDetailSheet: View { } .font(.system(size: 12, design: .monospaced)) .foregroundColor(.secondary) - - if let publishedAt = server.createdAt ?? server.meta?.official?.publishedAt { + + if let publishedAt = meta?.official?.publishedAt { dateMetadataTag(title: "Published ", dateString: publishedAt, image: "clock.arrow.trianglehead.counterclockwise.rotate.90") } - - if let updatedAt = server.updatedAt ?? server.meta?.official?.updatedAt { + + 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) { @@ -177,8 +178,10 @@ struct MCPServerDetailSheet: View { .tag(TabType.Packages) Text("Remotes (\(server.remotes?.count ?? 0))") .tag(TabType.Remotes) - Text("Metadata") - .tag(TabType.Metadata) + if meta?.official != nil { + Text("Metadata") + .tag(TabType.Metadata) + } } .pickerStyle(.segmented) } @@ -212,17 +215,15 @@ struct MCPServerDetailSheet: View { let optionInstalled = MCPRegistryService.shared.isPackageOptionInstalled(serverDetail: server, package: package) let metadata: [ServerInstallationOptionView.Metadata] = { var rows: [ServerInstallationOptionView.Metadata] = [] - if let identifier = package.identifier { - rows.append(.init(label: "ID", value: identifier, monospaced: true)) - } - if let registryURL = package.registryBaseURL { + 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 ?? "Package", + title: package.registryType.registryDisplayText, iconSystemName: "shippingbox", versionTag: package.version, metadata: metadata, @@ -296,42 +297,9 @@ struct MCPServerDetailSheet: View { private var metadataTab: some View { VStack(alignment: .leading, spacing: 16) { - if let meta = server.meta { - if let official = meta.official { - officialMetadataSection(official) - } - - } - - if server.meta == nil { - EmptyStateView( - message: "No metadata available", - type: .Metadata - ) - } - } - } - - private func repositorySection(_ repo: Repository) -> some View { - VStack(alignment: .leading, spacing: 12) { - Text("Repository") - .font(.system(size: 14, weight: .medium)) - - VStack(alignment: .leading, spacing: 8) { - metadataRow(label: "Source", value: repo.source) - metadataRow(label: "URL", value: repo.url, isLink: true) - if let id = repo.id { - metadataRow(label: "ID", value: id) - } - if let subfolder = repo.subfolder { - metadataRow(label: "Subfolder", value: subfolder) - } + if let officialMeta = meta?.official { + officialMetadataSection(officialMeta) } - .padding(12) - .background( - RoundedRectangle(cornerRadius: 6) - .fill(Color(nsColor: .controlBackgroundColor).opacity(0.5)) - ) } } @@ -343,19 +311,23 @@ struct MCPServerDetailSheet: View { } VStack(alignment: .leading, spacing: 8) { - metadataRow(label: "Server ID", value: official.id) - metadataRow( - label: "Published", - value: parseDate(official.publishedAt) != nil ? formatExactDate( - parseDate(official.publishedAt)! - ) : official.publishedAt - ) - metadataRow( - label: "Updated", - value: parseDate(official.updatedAt) != nil ? formatExactDate( - parseDate(official.updatedAt)! - ) : official.updatedAt - ) + 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) @@ -370,40 +342,6 @@ struct MCPServerDetailSheet: View { ) } - private func publisherMetadataSection(_ publisher: PublisherProvidedMeta) -> some View { - VStack(alignment: .leading, spacing: 12) { - Text("Build Information") - .font(.system(size: 14, weight: .medium)) - - VStack(alignment: .leading, spacing: 8) { - if let tool = publisher.tool { - metadataRow(label: "Tool", value: tool) - } - if let version = publisher.version { - metadataRow(label: "Version", value: version) - } - if let buildInfo = publisher.buildInfo { - if let commit = buildInfo.commit { - metadataRow(label: "Commit", value: String(commit.prefix(8))) - } - if let timestamp = buildInfo.timestamp { - metadataRow( - label: "Built", - value: parseDate(timestamp) != nil ? formatExactDate( - parseDate(timestamp)! - ) : timestamp - ) - } - } - } - .padding(12) - .background( - RoundedRectangle(cornerRadius: 6) - .fill(Color(nsColor: .controlBackgroundColor).opacity(0.5)) - ) - } - } - private func metadataRow(label: String, value: String, isLink: Bool = false) -> some View { HStack(spacing: 8) { Text(label) @@ -483,8 +421,8 @@ struct MCPServerDetailSheet: View { // Cache generated config for preview if needed later if packageConfigs[index] == nil { packageConfigs[index] = config } let option = InstallationOption( - displayName: package.registryType?.registryDisplayText ?? "Package", - description: "Install \(package.identifier ?? server.name)", + displayName: package.registryType.registryDisplayText, + description: "Install \(package.identifier)", config: config ) do { diff --git a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerGalleryView.swift b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerGalleryView.swift index 59d59cd7..0082b480 100644 --- a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerGalleryView.swift +++ b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerGalleryView.swift @@ -65,20 +65,21 @@ enum MCPServerGalleryWindow { // MARK: - Stable ID helper -extension MCPRegistryServerDetail { +extension MCPRegistryServerResponse { var stableID: String { - meta?.official?.id ?? repository?.id ?? name + server.name + server.version } } -private struct IdentifiableServer: Identifiable { - let server: MCPRegistryServerDetail - var id: String { server.stableID } +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 @@ -121,6 +122,16 @@ struct MCPServerGalleryView: View { } } .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() }) { @@ -133,7 +144,7 @@ struct MCPServerGalleryView: View { Button(action: { isShowingURLSheet = true }) { Image(systemName: "square.and.pencil") } - .help("Configure your MCP Registry URL") + .help("Configure your MCP Registry Base URL") } } } @@ -248,9 +259,9 @@ struct MCPServerGalleryView: View { // MARK: - Subviews - private func row(for server: MCPRegistryServerDetail, index: Int, isInstalled: Bool) -> some View { + private func row(for response: MCPRegistryServerResponse, index: Int, isInstalled: Bool) -> some View { HStack { - Text(server.name) + Text(response.server.title ?? response.server.name) .fontWeight(.medium) .lineLimit(1) .truncationMode(.middle) @@ -259,7 +270,7 @@ struct MCPServerGalleryView: View { Divider().frame(height: 20).foregroundColor(Color.clear) - Text(server.description) + Text(response.server.description) .fontWeight(.medium) .foregroundColor(.secondary) .lineLimit(1) @@ -270,44 +281,38 @@ struct MCPServerGalleryView: View { if isInstalled { Button("Uninstall") { Task { - await viewModel.uninstallServer(server) + await viewModel.uninstallServer(response.server) } } .buttonStyle(DestructiveButtonStyle()) .help("Uninstall") } else { - if #available(macOS 13.0, *) { - SplitButton( - title: "Install", - isDisabled: viewModel.hasNoDeployments(server), - primaryAction: { - // Install with default configuration - Task { - await viewModel.installServer(server) - } - }, - menuItems: viewModel.getInstallationOptions(for: server).map { option in + 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(server, configuration: option.displayName) + await viewModel.installServer(response.server, configuration: option.displayName) } } } - ) - .help("Install") - } else { - Button("Install") { - Task { - await viewModel.installServer(server) - } - } - .disabled(viewModel.hasNoDeployments(server)) - .help("Install") - } + }() + ) + .help("Install") } Button { - viewModel.showInfo(server) + viewModel.showInfo(response) } label: { Image(systemName: "info.circle") .font(.system(size: 13)) @@ -324,12 +329,8 @@ struct MCPServerGalleryView: View { .padding(.vertical, 10) } - private func infoSheet(_ server: MCPRegistryServerDetail) -> some View { - if #available(macOS 13.0, *) { - return AnyView(MCPServerDetailSheet(server: server)) - } else { - return AnyView(EmptyView()) - } + private func infoSheet(_ response: MCPRegistryServerResponse) -> some View { + MCPServerDetailSheet(response: response) } } @@ -339,7 +340,7 @@ func defaultInstallation(for server: MCPRegistryServerDetail) -> String { return firstRemote.transportType.rawValue } if let firstPackage = server.packages?.first { - return firstPackage.registryType ?? "" + return firstPackage.registryType } return "" } diff --git a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerGalleryViewModel.swift b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerGalleryViewModel.swift index 09c5894d..26cfaf63 100644 --- a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerGalleryViewModel.swift +++ b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerGalleryViewModel.swift @@ -11,11 +11,10 @@ final class MCPServerGalleryViewModel: ObservableObject { private let pageSize: Int // User / UI state - @Published var isSearchBarVisible: Bool = false @Published var searchText: String = "" // Data - @Published private(set) var servers: [MCPRegistryServerDetail] + @Published private(set) var servers: [MCPRegistryServerResponse] @Published private(set) var installedServers: Set = [] @Published private(set) var registryMetadata: MCPRegistryServerListMetadata? @@ -25,12 +24,13 @@ final class MCPServerGalleryViewModel: ObservableObject { @Published private(set) var isRefreshing: Bool = false // Transient presentation state - @Published var pendingServer: MCPRegistryServerDetail? - @Published var infoSheetServer: MCPRegistryServerDetail? + @Published var pendingServer: MCPRegistryServerResponse? + @Published var infoSheetServer: MCPRegistryServerResponse? @Published var mcpRegistryEntry: MCPRegistryEntry? @Published private(set) var lastError: Error? - @AppStorage(\.mcpRegistryURL) var mcpRegistryURL + @AppStorage(\.mcpRegistryBaseURL) var mcpRegistryBaseURL + @AppStorage(\.mcpRegistryBaseURLHistory) private var mcpRegistryBaseURLHistory // Service integration private let registryService = MCPRegistryService.shared @@ -48,19 +48,15 @@ final class MCPServerGalleryViewModel: ObservableObject { // MARK: - Derived Data - var filteredServers: [MCPRegistryServerDetail] { - // First filter for only latest official servers - let latestServers = servers.filter { server in - server.meta?.official?.isLatest == true - } - - // Then apply search filter if search text is present - let key = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard !key.isEmpty else { return latestServers } - - return latestServers.filter { - $0.name.lowercased().contains(key) || - $0.description.lowercased().contains(key) + 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 } } @@ -75,11 +71,11 @@ final class MCPServerGalleryViewModel: ObservableObject { 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) + return registryService.isServerInstalled(server.server) } // Fallback to the existing key-based check for backwards compatibility - let key = createRegistryServerKey(registryURL: mcpRegistryURL, serverId: serverId) + let key = createRegistryServerKey(registryBaseURL: mcpRegistryBaseURL, serverName: serverId) return installedServers.contains(key) } @@ -149,12 +145,12 @@ final class MCPServerGalleryViewModel: ObservableObject { isRefreshing = true defer { isRefreshing = false } - // Clear the current server list + // Clear the current server list and search text servers = [] registryMetadata = nil searchText = "" - // Load servers from the base URL + // Load servers from the base URL with empty query _ = await loadServerList(resetToFirstPage: true) } } @@ -164,7 +160,7 @@ final class MCPServerGalleryViewModel: ObservableObject { isRefreshing = true defer { isRefreshing = false } - // Clear the current server list immediately + // Clear the current server list and reset search text when URL changes servers = [] registryMetadata = nil searchText = "" @@ -196,7 +192,22 @@ final class MCPServerGalleryViewModel: ObservableObject { Logger.client.info("Cleared gallery view model data") } - func showInfo(_ server: MCPRegistryServerDetail) { + /// 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 } @@ -236,11 +247,15 @@ final class MCPServerGalleryViewModel: ObservableObject { let service = try getService() let cursor = resetToFirstPage ? nil : registryMetadata?.nextCursor + let trimmedQuery = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + let serverList = try await service.listMCPRegistryServers( .init( - baseUrl: mcpRegistryURL, + baseUrl: registryService.getRegistryURL(), cursor: cursor, - limit: pageSize + limit: pageSize, + search: trimmedQuery.isEmpty ? nil : trimmedQuery, + version: "latest" ) ) @@ -253,6 +268,8 @@ final class MCPServerGalleryViewModel: ObservableObject { servers.append(contentsOf: serverList?.servers ?? []) registryMetadata = serverList?.metadata } + + mcpRegistryBaseURLHistory.addToHistory(mcpRegistryBaseURL) return nil } catch { @@ -279,18 +296,20 @@ final class MCPServerGalleryViewModel: ObservableObject { let serverConfigDict = serverConfig as? [String: Any], let metadata = serverConfigDict["x-metadata"] as? [String: Any], let registry = metadata["registry"] as? [String: Any], - let registryUrl = registry["url"] as? String, - let serverId = registry["serverId"] as? String + 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(registryURL: registryUrl, serverId: serverId) + createRegistryServerKey(registryBaseURL: baseUrl, serverName: name) ) } } - private func createRegistryServerKey(registryURL: String, serverId: String) -> String { - return registryService.createRegistryServerKey(registryURL: registryURL, serverId: serverId) + private func createRegistryServerKey(registryBaseURL: String, serverName: String) -> String { + return registryService.createRegistryServerKey(registryBaseURL: registryBaseURL, serverName: serverName) } // MARK: - Installation Options Helper diff --git a/Core/Sources/HostApp/ToolsSettings/MCPServerToolsSection.swift b/Core/Sources/HostApp/ToolsSettings/MCPServerToolsSection.swift index a34c938c..47abc27a 100644 --- a/Core/Sources/HostApp/ToolsSettings/MCPServerToolsSection.swift +++ b/Core/Sources/HostApp/ToolsSettings/MCPServerToolsSection.swift @@ -4,16 +4,24 @@ 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)") @@ -44,7 +52,6 @@ struct MCPServerToolsSection: View { .foregroundStyle(.secondary) .font(.system(size: 11)) } - Spacer() } } @@ -55,9 +62,9 @@ struct MCPServerToolsSection: View { private func createErrorMessage(_ baseMessage: String) -> AttributedString { if hasServerConfigPlaceholders() { - var attributedString = AttributedString(baseMessage) - attributedString.append(AttributedString(". You may need to update placeholders in ")) - + 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 @@ -72,15 +79,43 @@ struct MCPServerToolsSection: View { } 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 || serverTools.status == .blocked) } private var divider: some View { @@ -100,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) @@ -107,6 +143,7 @@ struct MCPServerToolsSection: View { } .onChange(of: serverTools) { newValue in initializeToolStates(server: newValue) + updateMixedState() } } @@ -117,7 +154,9 @@ struct MCPServerToolsSection: View { 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 { @@ -129,6 +168,7 @@ struct MCPServerToolsSection: View { } .onAppear { initializeToolStates(server: serverTools) + updateMixedState() if forceExpand { isExpanded = true } @@ -138,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 { @@ -187,15 +278,27 @@ struct MCPServerToolsSection: View { private func initializeToolStates(server: MCPServerToolsCollection) { var disabled = 0 - toolEnabledStates = server.tools.reduce(into: [:]) { result, tool in - result[tool.name] = tool._status == .enabled - disabled += result[tool.name]! ? 0 : 1 + let newStates: [String: Bool] = server.tools.reduce(into: [:]) { result, tool in + let isEnabled = isToolEnabledInMode(tool.name, currentStatus: tool._status) + result[tool.name] = isEnabled + disabled += isEnabled ? 0 : 1 + } + + for (toolName, newState) in newStates { + if toolEnabledStates[toolName] != newState { + toolEnabledStates[toolName] = newState + } + } + + for existingToolName in toolEnabledStates.keys { + if newStates[existingToolName] == nil { + toolEnabledStates.removeValue(forKey: existingToolName) + } } let enabled = toolEnabledStates.count - disabled Logger.client.info("Server \(server.name) initialized with \(toolEnabledStates.count) tools (\(enabled) enabled, \(disabled) disabled).") - // Check if all tools are disabled to properly set server state if !toolEnabledStates.isEmpty && toolEnabledStates.values.allSatisfy({ !$0 }) { DispatchQueue.main.async { isServerEnabled = false @@ -205,7 +308,9 @@ struct MCPServerToolsSection: View { private func toolBindingFor(_ tool: MCPTool) -> Binding { Binding( - get: { toolEnabledStates[tool.name] ?? (tool._status == .enabled) }, + get: { + toolEnabledStates[tool.name] ?? isToolEnabledInMode(tool.name, currentStatus: tool._status) + }, set: { toolEnabledStates[tool.name] = $0 } ) } @@ -216,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) } @@ -261,18 +369,91 @@ struct MCPServerToolsSection: View { 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/MCPXcodeServerInstallView.swift b/Core/Sources/HostApp/ToolsSettings/MCPXcodeServerInstallView.swift new file mode 100644 index 00000000..3727111d --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/MCPXcodeServerInstallView.swift @@ -0,0 +1,224 @@ +import GitHubCopilotService +import Logger +import SharedUIComponents +import SwiftUI +import SystemUtils + +struct MCPXcodeServerInstallView: View { + @State private var xcodeVersion: String? = SystemUtils.xcodeVersion + @State private var isConfigured: Bool = false + @State private var isInstalling: Bool = false + @State private var installError: String? = nil + /// Server names from mcp.json whose config matches xcrun mcpbridge. + /// Cached to avoid repeated file I/O during SwiftUI rendering. + @State private var configuredXcodeServerNames: Set = [] + @ObservedObject private var mcpToolManager = CopilotMCPToolManagerObservable.shared + + private let requiredXcodeVersion = "26.4" + private let serverName = "xcode" + + private var meetsVersionRequirement: Bool { + guard let version = xcodeVersion else { return false } + return version.compare(requiredXcodeVersion, options: .numeric) != .orderedAscending + } + + private var isConnected: Bool { + mcpToolManager.availableMCPServerTools.contains { server in + configuredXcodeServerNames.contains(server.name) && + server.status == .running && + !server.tools.isEmpty + } + } + + /// Configured in mcp.json but not yet showing in available tools from the language server + private var isConfiguredButNotConnected: Bool { + isConfigured && !isConnected + } + + private var isAlreadyInstalled: Bool { + isConfigured || isConnected + } + + var body: some View { + HStack(alignment: .center, spacing: 16) { + VStack(alignment: .leading, spacing: 0) { + Text("Xcode MCP Server") + .font(.headline) + .padding(.vertical, 4) + + subtitleView() + .font(.subheadline) + .foregroundColor(.secondary) + } + + Spacer() + + actionsView() + .padding(.vertical, 12) + } + .padding(EdgeInsets(top: 8, leading: 20, bottom: 8, trailing: 20)) + .background(QuaternarySystemFillColor.opacity(0.75)) + .settingsContainerStyle(isExpanded: false) + .onAppear { + checkInstallationStatus() + } + .onChange(of: mcpToolManager.availableMCPServerTools) { _ in + checkInstallationStatus() + } + } + + // MARK: - Subviews + + @ViewBuilder + private func subtitleView() -> some View { + if !meetsVersionRequirement { + let versionText = xcodeVersion ?? "unknown" + Text("Requires Xcode \(requiredXcodeVersion) or later. Current version: \(versionText).") + } else if isConnected { + Text("Xcode's built-in MCP server is connected, enabling richer editor integration.") + } else if isConfiguredButNotConnected { + Text("Please confirm in Xcode to allow the built-in MCP server.") + } else { + VStack(alignment: .leading, spacing: 4) { + Text("Connect Copilot to Xcode’s built‑in MCP server to enable richer editor integration.") + if let installError { + Text(installError) + .font(.caption) + .foregroundColor(.red) + } + } + } + } + + @ViewBuilder + private func actionsView() -> some View { + if !meetsVersionRequirement { + EmptyView() + } else if isConnected { + Text("Connected").foregroundColor(.secondary) + } else if isConfiguredButNotConnected { + HStack(spacing: 6) { + ProgressView() + .controlSize(.small) + Text("Waiting for connection...") + .foregroundColor(.secondary) + } + } else { + Button { + installXcodeMCPServer() + } label: { + HStack(spacing: 4) { + if isInstalling { + ProgressView() + .controlSize(.small) + } else { + Image(systemName: "plus.circle") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 12, height: 12, alignment: .center) + .padding(2) + } + Text("Install") + } + .conditionalFontWeight(.semibold) + } + .buttonStyle(.bordered) + .disabled(isInstalling) + } + } + + // MARK: - Actions + + private func checkInstallationStatus() { + let (configured, names) = readXcodeMCPServerNamesFromConfig() + isConfigured = configured + configuredXcodeServerNames = names + } + + /// Returns (isConfigured, setOfMatchingServerNames) by reading mcp.json once. + private func readXcodeMCPServerNamesFromConfig() -> (Bool, Set) { + let configFileURL = URL(fileURLWithPath: mcpConfigFilePath) + guard FileManager.default.fileExists(atPath: configFileURL.path), + let data = try? Data(contentsOf: configFileURL), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let servers = json["servers"] as? [String: Any] + else { + return (false, []) + } + + var names = Set() + for (key, value) in servers { + guard let serverConfig = value as? [String: Any] else { continue } + let command = serverConfig["command"] as? String ?? "" + let args = serverConfig["args"] as? [String] ?? [] + if command.contains("xcrun") && args.contains(where: { $0.contains("mcpbridge") }) { + names.insert(key) + } + } + return (!names.isEmpty, names) + } + + private func installXcodeMCPServer() { + isInstalling = true + installError = nil + + let configFileURL = URL(fileURLWithPath: mcpConfigFilePath) + let fileManager = FileManager.default + + do { + if !fileManager.fileExists(atPath: configDirectory.path) { + try fileManager.createDirectory( + at: configDirectory, + withIntermediateDirectories: true + ) + } + + var config: [String: Any] + if fileManager.fileExists(atPath: configFileURL.path), + let data = try? Data(contentsOf: configFileURL), + let existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + { + config = existing + } else { + config = ["servers": [String: Any]()] + } + + var servers = config["servers"] as? [String: Any] ?? [:] + + // Skip write if the entry already points to xcrun mcpbridge + if let existing = servers[serverName] as? [String: Any], + let command = existing["command"] as? String, + let args = existing["args"] as? [String], + command.contains("xcrun") && args.contains(where: { $0.contains("mcpbridge") }) + { + isConfigured = true + configuredXcodeServerNames.insert(serverName) + isInstalling = false + return + } + + servers[serverName] = [ + "type": "stdio", + "command": "xcrun", + "args": ["mcpbridge"] + ] + + config["servers"] = servers + + let jsonData = try JSONSerialization.data( + withJSONObject: config, + options: [.prettyPrinted, .sortedKeys] + ) + try jsonData.write(to: configFileURL, options: .atomic) + + isConfigured = true + configuredXcodeServerNames.insert(serverName) + Logger.client.info("Successfully added Xcode MCP Server to configuration") + } catch { + installError = "Failed to update configuration: \(error.localizedDescription)" + Logger.client.error("Failed to install Xcode MCP Server: \(error)") + } + + isInstalling = false + } +} diff --git a/Core/Sources/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/LaunchAgentManager/LaunchAgentManager.swift b/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift index c311439d..1cee8bf2 100644 --- a/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift +++ b/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift @@ -24,17 +24,10 @@ public struct LaunchAgentManager { } public func setupLaunchAgentForTheFirstTimeIfNeeded() async throws { - if #available(macOS 13, *) { - await removeObsoleteLaunchAgent() - try await setupLaunchAgent() - } else { - guard !FileManager.default.fileExists(atPath: launchAgentPath) else { return } - try await setupLaunchAgent() - await removeObsoleteLaunchAgent() - } + await removeObsoleteLaunchAgent() + try await setupLaunchAgent() } - @available(macOS 13.0, *) public func isBackgroundPermissionGranted() async -> Bool { // On macOS 13+, check SMAppService status let bridgeLaunchAgent = SMAppService.agent(plistName: "bridgeLaunchAgent.plist") @@ -43,87 +36,41 @@ public struct LaunchAgentManager { } public func setupLaunchAgent() async throws { - if #available(macOS 13, *) { - Logger.client.info("Registering bridge launch agent") - let bridgeLaunchAgent = SMAppService.agent(plistName: "bridgeLaunchAgent.plist") - try bridgeLaunchAgent.register() - } else { - Logger.client.info("Creating and loading bridge launch agent") - let content = """ - - - - - Label - \(serviceIdentifier) - Program - \(executablePath) - MachServices - - \(serviceIdentifier) - - - AssociatedBundleIdentifiers - - \(bundleIdentifier) - \(serviceIdentifier) - - - - """ - if !FileManager.default.fileExists(atPath: launchAgentDirURL.path) { - try FileManager.default.createDirectory( - at: launchAgentDirURL, - withIntermediateDirectories: false - ) - } - FileManager.default.createFile( - atPath: launchAgentPath, - contents: content.data(using: .utf8) - ) - try await launchctl("load", launchAgentPath) - } + Logger.client.info("Registering bridge launch agent") + let bridgeLaunchAgent = SMAppService.agent(plistName: "bridgeLaunchAgent.plist") + try bridgeLaunchAgent.register() let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String UserDefaults.standard.set(buildNumber, forKey: lastLaunchAgentVersionKey) } public func removeLaunchAgent() async throws { - if #available(macOS 13, *) { - Logger.client.info("Unregistering bridge launch agent") - let bridgeLaunchAgent = SMAppService.agent(plistName: "bridgeLaunchAgent.plist") - try await bridgeLaunchAgent.unregister() - } else { - Logger.client.info("Unloading and removing bridge launch agent") - try await launchctl("unload", launchAgentPath) - try FileManager.default.removeItem(atPath: launchAgentPath) - } + Logger.client.info("Unregistering bridge launch agent") + let bridgeLaunchAgent = SMAppService.agent(plistName: "bridgeLaunchAgent.plist") + try await bridgeLaunchAgent.unregister() } public func reloadLaunchAgent() async throws { - if #unavailable(macOS 13) { - Logger.client.info("Reloading bridge launch agent") - try await helper("reload-launch-agent", "--service-identifier", serviceIdentifier) - } + // No-op: macOS 13+ uses SMAppService which doesn't need manual reload } public func removeObsoleteLaunchAgent() async { - if #available(macOS 13, *) { - let path = launchAgentPath - if FileManager.default.fileExists(atPath: path) { - Logger.client.info("Unloading and removing old bridge launch agent") - try? await launchctl("unload", path) - try? FileManager.default.removeItem(atPath: path) - } - } else { - let path = launchAgentPath.replacingOccurrences( - of: "ExtensionService", - with: "XPCService" - ) - if FileManager.default.fileExists(atPath: path) { - Logger.client.info("Removing old bridge launch agent plist") - try? FileManager.default.removeItem(atPath: path) - } + let path = launchAgentPath + if FileManager.default.fileExists(atPath: path) { + Logger.client.info("Unloading and removing old bridge launch agent") + try? await launchctl("unload", path) + try? FileManager.default.removeItem(atPath: path) + } + + // Also remove legacy plist that used "XPCService" instead of "ExtensionService" + let legacyIdentifier = serviceIdentifier + .replacingOccurrences(of: "ExtensionService", with: "XPCService") + let legacyPath = launchAgentDirURL + .appendingPathComponent("\(legacyIdentifier).plist").path + if FileManager.default.fileExists(atPath: legacyPath) { + Logger.client.info("Unloading and removing legacy XPCService launch agent") + try? await launchctl("unload", legacyPath) + try? FileManager.default.removeItem(atPath: legacyPath) } } } diff --git a/Core/Sources/PersistMiddleware/Extensions/ChatMessage+Storage.swift b/Core/Sources/PersistMiddleware/Extensions/ChatMessage+Storage.swift index 0fc5c849..d1837411 100644 --- a/Core/Sources/PersistMiddleware/Extensions/ChatMessage+Storage.swift +++ b/Core/Sources/PersistMiddleware/Extensions/ChatMessage+Storage.swift @@ -16,10 +16,13 @@ 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 { @@ -33,10 +36,13 @@ 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 @@ -50,10 +56,13 @@ extension ChatMessage { errorMessages: [String] = [], steps: [ConversationProgressStep]?, editAgentRounds: [AgentRound]? = nil, + parentTurnId: String? = nil, panelMessages: [CopilotShowMessageParams]? = nil, fileEdits: [FileEdit]? = nil, turnStatus: ChatMessage.TurnStatus? = nil, - requestType: RequestType = .conversation + requestType: RequestType = .conversation, + modelName: String? = nil, + billingMultiplier: Float? = nil ) { self.content = content self.contentImageReferences = contentImageReferences ?? [] @@ -64,10 +73,13 @@ 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 } } @@ -82,10 +94,13 @@ extension ChatMessage { errorMessages: self.errorMessages, steps: self.steps, editAgentRounds: self.editAgentRounds, + parentTurnId: self.parentTurnId, panelMessages: self.panelMessages, fileEdits: self.fileEdits, turnStatus: self.turnStatus, - requestType: self.requestType + requestType: self.requestType, + modelName: self.modelName, + billingMultiplier: self.billingMultiplier ) // TODO: handle exception @@ -118,10 +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/RealtimeSuggestionController.swift b/Core/Sources/Service/RealtimeSuggestionController.swift index 899865f1..d583d792 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 { @@ -95,16 +88,9 @@ public actor RealtimeSuggestionController { ) } - if #available(macOS 13.0, *) { - for await _ in selectedTextChanged._throttle(for: .milliseconds(200)) { - if Task.isCancelled { return } - await handler() - } - } else { - for await _ in selectedTextChanged { - if Task.isCancelled { return } - await handler() - } + for await _ in selectedTextChanged._throttle(for: .milliseconds(200)) { + if Task.isCancelled { return } + await handler() } } @@ -155,9 +141,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 1801a51e..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) + } } } @@ -383,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) @@ -423,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 @@ -444,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..7e1fa514 --- /dev/null +++ b/Core/Sources/SuggestionWidget/AgentConfigurationWidgetView.swift @@ -0,0 +1,1190 @@ +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", + isDegraded: model.degradationReason != nil + )) + } + + 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] ?? "", + isDegraded: model.degradationReason != nil + )) + } + } + } + } + + // 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, + isDegraded: Bool = false + ) -> AttributedString { + return ModelMenuItemFormatter.createModelMenuItemAttributedString( + modelName: modelName, + isSelected: isSelected, + multiplierText: multiplierText, + targetWidth: targetMenuItemWidth, + isDegraded: isDegraded + ) + } + } +} diff --git a/Core/Sources/SuggestionWidget/ChatWindow/ChatLoginView.swift b/Core/Sources/SuggestionWidget/ChatWindow/ChatLoginView.swift index 4223e2b2..0a70e8ba 100644 --- a/Core/Sources/SuggestionWidget/ChatWindow/ChatLoginView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindow/ChatLoginView.swift @@ -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/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift index 665ccd70..0e1fc9e4 100644 --- a/Core/Sources/SuggestionWidget/ChatWindowView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift @@ -232,11 +232,7 @@ struct ChatTitleBar: View { private extension View { func hideScrollIndicator() -> some View { - if #available(macOS 13.0, *) { - return scrollIndicators(.hidden) - } else { - return self - } + scrollIndicators(.hidden) } } diff --git a/Core/Sources/SuggestionWidget/CodeReviewPanelView.swift b/Core/Sources/SuggestionWidget/CodeReviewPanelView.swift index 214996a8..f7359baa 100644 --- a/Core/Sources/SuggestionWidget/CodeReviewPanelView.swift +++ b/Core/Sources/SuggestionWidget/CodeReviewPanelView.swift @@ -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 a7493e86..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 { diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift index 682d9c79..9462717f 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift @@ -332,13 +332,7 @@ extension PromptToCodePanel { codeForegroundColor: codeForegroundColor ) } - .modify { - if #available(macOS 13.0, *) { - $0.scrollIndicators(.hidden) - } else { - $0 - } - } + .scrollIndicators(.hidden) } } } diff --git a/Core/Sources/SuggestionWidget/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..5a2b9c0f 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() { @@ -233,42 +240,24 @@ private extension WidgetWindowsController { let valueChange = await editor.axNotifications.notifications() .filter { $0.kind == .valueChanged } - if #available(macOS 13.0, *) { - for await notification in merge( - scroll, - selectionRangeChange.debounce(for: Duration.milliseconds(0)), - valueChange.debounce(for: Duration.milliseconds(100)) - ) { - guard await xcodeInspector.safe.latestActiveXcode != nil else { return } - try Task.checkCancellation() - - // for better looking - if notification.kind == .scrollPositionChanged { - await hideSuggestionPanelWindow() - } + for await notification in merge( + scroll, + selectionRangeChange.debounce(for: Duration.milliseconds(0)), + valueChange.debounce(for: Duration.milliseconds(100)) + ) { + guard await xcodeInspector.safe.latestActiveXcode != nil else { return } + try Task.checkCancellation() - updateWindowLocation(animated: false, immediately: false) - updateWindowOpacity(immediately: false) - await updateCodeReviewWindowLocation(.onSourceEditorNotification(notification)) - - await handleFixErrorEditorNotification(notification: notification) + // for better looking + if notification.kind == .scrollPositionChanged { + await hideSuggestionPanelWindow() } - } else { - for await notification in merge(selectionRangeChange, scroll, valueChange) { - guard await xcodeInspector.safe.latestActiveXcode != nil else { return } - try Task.checkCancellation() - // for better looking - if notification.kind == .scrollPositionChanged { - await hideSuggestionPanelWindow() - } + updateWindowLocation(animated: false, immediately: false) + updateWindowOpacity(immediately: false) + await updateCodeReviewWindowLocation(.onSourceEditorNotification(notification)) - updateWindowLocation(animated: false, immediately: false) - updateWindowOpacity(immediately: false) - await updateCodeReviewWindowLocation(.onSourceEditorNotification(notification)) - - await handleFixErrorEditorNotification(notification: notification) - } + await handleFixErrorEditorNotification(notification: notification) } } } @@ -302,7 +291,7 @@ extension WidgetWindowsController { @MainActor func hideSuggestionPanelWindow() { windows.suggestionPanelWindow.alphaValue = 0 - send(.panel(.hidePanel)) + send(.panel(.hidePanel(.suggestion))) } @MainActor @@ -318,9 +307,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 +322,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 +336,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 +360,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 +380,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 +402,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 +413,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 +439,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 +483,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 +511,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 +536,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 +617,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 +644,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 +1054,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 +1201,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 +1301,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/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 a0dd47c6..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 } @@ -346,6 +360,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { self.toggleCompletions.isHidden = false self.toggleIgnoreLanguage.isHidden = false + self.toggleNES.isHidden = false self.signOutItem.isHidden = false } @@ -398,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 } @@ -411,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 } 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/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/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 index 3c56d13b..552c7769 100644 --- a/ExtensionService/Assets.xcassets/LightBlue.colorset/Contents.json +++ b/ExtensionService/Assets.xcassets/LightBlue.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "255", - "green" : "226", - "red" : "212" + "blue" : "0xFF", + "green" : "0xE2", + "red" : "0xD4" } }, "idiom" : "universal" 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/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/WarningYellow.colorset/Contents.json b/ExtensionService/Assets.xcassets/WarningYellow.colorset/Contents.json new file mode 100644 index 00000000..fb5231df --- /dev/null +++ b/ExtensionService/Assets.xcassets/WarningYellow.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0x04", + "green" : "0x7D", + "red" : "0xC2" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0x5C", + "green" : "0xC5", + "red" : "0xF2" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/XPCController.swift b/ExtensionService/XPCController.swift index 02656f85..d47ea0ef 100644 --- a/ExtensionService/XPCController.swift +++ b/ExtensionService/XPCController.swift @@ -59,7 +59,7 @@ final class XPCController: XPCServiceDelegate { // No log, but you should run CommunicationBridge, too. #else if consecutiveFailures == 5 { - if #available(macOS 13.0, *) { + await MainActor.run { showBackgroundPermissionAlert() } } diff --git a/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 e9207fec..54e04ee5 100644 --- a/ReleaseNotes.md +++ b/ReleaseNotes.md @@ -1,23 +1,18 @@ -### GitHub Copilot for Xcode 0.44.0 +### GitHub Copilot for Xcode 0.47.0 **πŸš€ Highlights** -* 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. +- **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** -* 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. +- Refined the working set header. +- Improved the details view for MCP tool calls. **πŸ› οΈ Bug Fixes** -* Fixed a command injection vulnerability when opening referenced chat files. -* Resolved display issues in the chat view on macOS 26. +- 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 54c27c8f..855a73b1 100644 --- a/Server/package-lock.json +++ b/Server/package-lock.json @@ -8,19 +8,19 @@ "name": "@github/copilot-xcode", "version": "0.0.1", "dependencies": { - "@github/copilot-language-server": "1.387.0", - "@github/copilot-language-server-darwin-arm64": "1.387.0", - "@github/copilot-language-server-darwin-x64": "1.387.0", + "@github/copilot-language-server": "1.451.0", + "@github/copilot-language-server-darwin-arm64": "1.451.0", + "@github/copilot-language-server-darwin-x64": "1.451.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "monaco-editor": "0.52.2" }, "devDependencies": { "@types/node": "^22.15.17", - "copy-webpack-plugin": "^13.0.1", + "copy-webpack-plugin": "^14.0.0", "css-loader": "^7.1.2", "style-loader": "^4.0.0", - "terser-webpack-plugin": "^5.3.14", + "terser-webpack-plugin": "^5.4.0", "ts-loader": "^9.5.4", "typescript": "^5.8.3", "webpack": "^5.99.9", @@ -38,9 +38,9 @@ } }, "node_modules/@github/copilot-language-server": { - "version": "1.387.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.387.0.tgz", - "integrity": "sha512-QAFmB0VQZehU7L8luyKIkjPjfcyiW8y/im6FDGzAYsO0kc3P+qxf6V5R0KGweDfVVbOT5WgZOJrxonZMc6sIeg==", + "version": "1.451.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.451.0.tgz", + "integrity": "sha512-ApkyyC0yz1tx+9Yb17SjG0/jpmIgl3H1EO744Thyg+sCt6AsonJMoNTVUPcx0YxEzzK0HafUWeA/4nacTwnTYg==", "license": "MIT", "dependencies": { "vscode-languageserver-protocol": "^3.17.5" @@ -49,17 +49,18 @@ "copilot-language-server": "dist/language-server.js" }, "optionalDependencies": { - "@github/copilot-language-server-darwin-arm64": "1.387.0", - "@github/copilot-language-server-darwin-x64": "1.387.0", - "@github/copilot-language-server-linux-arm64": "1.387.0", - "@github/copilot-language-server-linux-x64": "1.387.0", - "@github/copilot-language-server-win32-x64": "1.387.0" + "@github/copilot-language-server-darwin-arm64": "1.451.0", + "@github/copilot-language-server-darwin-x64": "1.451.0", + "@github/copilot-language-server-linux-arm64": "1.451.0", + "@github/copilot-language-server-linux-x64": "1.451.0", + "@github/copilot-language-server-win32-arm64": "1.451.0", + "@github/copilot-language-server-win32-x64": "1.451.0" } }, "node_modules/@github/copilot-language-server-darwin-arm64": { - "version": "1.387.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-arm64/-/copilot-language-server-darwin-arm64-1.387.0.tgz", - "integrity": "sha512-ozITxHrxxQoiHb7EkXbdOi0BtRWsWaSrwHuzvPvAMm+cZctg/CM2US6AE6o3DAbvZirCsAr0MEJrVMq+QGdX/Q==", + "version": "1.451.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-arm64/-/copilot-language-server-darwin-arm64-1.451.0.tgz", + "integrity": "sha512-aq0dv9oKLt2Y87oEnnsUCWJ0x2qc+t+nyzg3GoT2M6eWr1YrqIL6VlGlmmNB/WWvTSp3w94xy5H6kDpD7rzWgQ==", "cpu": [ "arm64" ], @@ -69,9 +70,9 @@ ] }, "node_modules/@github/copilot-language-server-darwin-x64": { - "version": "1.387.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-x64/-/copilot-language-server-darwin-x64-1.387.0.tgz", - "integrity": "sha512-+yBRLqCAP76aRZnpazL+Ow1PAo8lEalFC7yumPNrI7xuF7DMyo86u+Uev1sY5diyYizmGAoO8zUGqmKJdDIXPg==", + "version": "1.451.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-x64/-/copilot-language-server-darwin-x64-1.451.0.tgz", + "integrity": "sha512-GX1Fkl84Bh1EpnNYQpexirKrIxgtpUU4iYh58b865dAv7TBpKIyXxP1rSl/2/MCWDV6VuPWYhv5OfzHuiFgacA==", "cpu": [ "x64" ], @@ -81,9 +82,9 @@ ] }, "node_modules/@github/copilot-language-server-linux-arm64": { - "version": "1.387.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-arm64/-/copilot-language-server-linux-arm64-1.387.0.tgz", - "integrity": "sha512-ymiWNiPycQuUmx4nr/wzpgPCl4caM+z55F8PKFtSXmMc0ZgrAD9Jvr6GO9T4IcyE2jc+OtFKRcF6IrrKWkiUsg==", + "version": "1.451.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-arm64/-/copilot-language-server-linux-arm64-1.451.0.tgz", + "integrity": "sha512-eNemo1Nj9ZPpE+FhDqQe1iRHQdZZCgRTmXZ/hrfc7slqDyDrMuMII18l7lLHspN2Po8hNZdJjrMvnk0J9mebSw==", "cpu": [ "arm64" ], @@ -94,9 +95,9 @@ ] }, "node_modules/@github/copilot-language-server-linux-x64": { - "version": "1.387.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-x64/-/copilot-language-server-linux-x64-1.387.0.tgz", - "integrity": "sha512-3DLlYt+FA0xj8/9iasyCYZ84PliMjYEPIyvyVdYqInZALu7tq78G7VVKv4xTNLKAAnOQPHB43hKPdMrICqbTjA==", + "version": "1.451.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-x64/-/copilot-language-server-linux-x64-1.451.0.tgz", + "integrity": "sha512-cnTYzJElUb3xw4Y8gylIuY3rWwTeNWPbI848yf8sZVxDo+P7U8Sfyfo0ZIxbwF2r48EohQZ0PA9Uu3Q0pX9dEA==", "cpu": [ "x64" ], @@ -106,10 +107,23 @@ "linux" ] }, + "node_modules/@github/copilot-language-server-win32-arm64": { + "version": "1.451.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-win32-arm64/-/copilot-language-server-win32-arm64-1.451.0.tgz", + "integrity": "sha512-9Wx2XRZJm+8Fy2Ho2kuupBQpXyj9pSJJXO+Xi2oFFBSdS9pAEpqx+62CMTqLLjlmDkFj9QW0rI5FNDynxSPBCQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@github/copilot-language-server-win32-x64": { - "version": "1.387.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-win32-x64/-/copilot-language-server-win32-x64-1.387.0.tgz", - "integrity": "sha512-kFoaFnPAbH6XTpf+8wN+dxhHwfE2Ww4ae9styIgP9Swx/9StpCTJp/Hkp6smL3zWjwL+mymWrhbSw2Tiy+klyg==", + "version": "1.451.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-win32-x64/-/copilot-language-server-win32-x64-1.451.0.tgz", + "integrity": "sha512-L9uqgeQNWGr9Vrpj8fYwazAJYn/TwqrhZ2r/euXi7wpg8fPTHh9JAmdBLI39Gr34kyclL1fxjzvNzm0UtRC0XA==", "cpu": [ "x64" ], @@ -707,20 +721,20 @@ "license": "MIT" }, "node_modules/copy-webpack-plugin": { - "version": "13.0.1", - "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-13.0.1.tgz", - "integrity": "sha512-J+YV3WfhY6W/Xf9h+J1znYuqTye2xkBUIGyTPWuBAT27qajBa5mR4f8WBmfDY3YjRftT2kqZZiLi1qf0H+UOFw==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-14.0.0.tgz", + "integrity": "sha512-3JLW90aBGeaTLpM7mYQKpnVdgsUZRExY55giiZgLuX/xTQRUs1dOCwbBnWnvY6Q6rfZoXMNwzOQJCSZPppfqXA==", "dev": true, "license": "MIT", "dependencies": { "glob-parent": "^6.0.1", "normalize-path": "^3.0.0", "schema-utils": "^4.2.0", - "serialize-javascript": "^6.0.2", + "serialize-javascript": "^7.0.3", "tinyglobby": "^0.2.12" }, "engines": { - "node": ">= 18.12.0" + "node": ">= 20.9.0" }, "funding": { "type": "opencollective", @@ -1550,16 +1564,6 @@ "dev": true, "license": "MIT" }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, "node_modules/rechoir": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", @@ -1627,27 +1631,6 @@ "node": ">=8" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/schema-utils": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz", @@ -1682,13 +1665,13 @@ } }, "node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz", + "integrity": "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==", "dev": true, "license": "BSD-3-Clause", - "dependencies": { - "randombytes": "^2.1.0" + "engines": { + "node": ">=20.0.0" } }, "node_modules/shallow-clone": { @@ -1834,16 +1817,15 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.3.14", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz", - "integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.4.0.tgz", + "integrity": "sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", - "serialize-javascript": "^6.0.2", "terser": "^5.31.1" }, "engines": { diff --git a/Server/package.json b/Server/package.json index a07cc3e7..3599040a 100644 --- a/Server/package.json +++ b/Server/package.json @@ -7,19 +7,19 @@ "build": "webpack" }, "dependencies": { - "@github/copilot-language-server": "1.387.0", - "@github/copilot-language-server-darwin-arm64": "1.387.0", - "@github/copilot-language-server-darwin-x64": "1.387.0", + "@github/copilot-language-server": "1.451.0", + "@github/copilot-language-server-darwin-arm64": "1.451.0", + "@github/copilot-language-server-darwin-x64": "1.451.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "monaco-editor": "0.52.2" }, "devDependencies": { "@types/node": "^22.15.17", - "copy-webpack-plugin": "^13.0.1", + "copy-webpack-plugin": "^14.0.0", "css-loader": "^7.1.2", "style-loader": "^4.0.0", - "terser-webpack-plugin": "^5.3.14", + "terser-webpack-plugin": "^5.4.0", "ts-loader": "^9.5.4", "typescript": "^5.8.3", "webpack": "^5.99.9", diff --git a/Tool/Package.swift b/Tool/Package.swift index 541990d8..64c6d2fb 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -5,7 +5,7 @@ import PackageDescription let package = Package( name: "Tool", - platforms: [.macOS(.v12)], + platforms: [.macOS(.v13)], products: [ .library(name: "XPCShared", targets: ["XPCShared"]), .library(name: "Terminal", targets: ["Terminal"]), @@ -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 81289093..14625052 100644 --- a/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift +++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift @@ -56,30 +56,34 @@ 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, @@ -149,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 { 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 77a88484..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) @@ -44,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 @@ -78,7 +138,6 @@ extension ChatMessage { self.steps = mergedSteps } - // merge agent steps if !message.editAgentRounds.isEmpty { let mergedAgentRounds = mergeEditAgentRounds( oldRounds: self.editAgentRounds, @@ -88,13 +147,17 @@ extension ChatMessage { self.editAgentRounds = mergedAgentRounds } + self.parentTurnId = message.parentTurnId ?? self.parentTurnId + self.codeReviewRound = message.codeReviewRound - // merge file edits self.fileEdits = mergeFileEdits(oldEdits: self.fileEdits, newEdits: message.fileEdits) - // merge turn status 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] { @@ -109,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 } @@ -124,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) } diff --git a/Tool/Sources/ChatAPIService/Models.swift b/Tool/Sources/ChatAPIService/Models.swift index 917fbb78..82337095 100644 --- a/Tool/Sources/ChatAPIService/Models.swift +++ b/Tool/Sources/ChatAPIService/Models.swift @@ -106,6 +106,8 @@ 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 @@ -114,6 +116,8 @@ public struct ChatMessage: Equatable, Codable { case user case assistant case system + + public var isAssistant: Bool { self == .assistant } } public enum TurnStatus: String, Codable, Equatable { @@ -157,6 +161,8 @@ public struct ChatMessage: Equatable, Codable { public var editAgentRounds: [AgentRound] + public var parentTurnId: String? + public var panelMessages: [CopilotShowMessageParams] public var codeReviewRound: CodeReviewRound? @@ -170,6 +176,10 @@ public struct ChatMessage: Equatable, Codable { 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 @@ -188,11 +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 ) { @@ -209,11 +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 @@ -248,10 +264,13 @@ public struct ChatMessage: Equatable, Codable { suggestedTitle: String? = nil, steps: [ConversationProgressStep] = [], editAgentRounds: [AgentRound] = [], + parentTurnId: String? = nil, codeReviewRound: CodeReviewRound? = nil, fileEdits: [FileEdit] = [], turnStatus: TurnStatus? = nil, - requestType: RequestType = .conversation + requestType: RequestType = .conversation, + modelName: String? = nil, + billingMultiplier: Float? = nil ) { self.init( id: id, @@ -264,10 +283,13 @@ public struct ChatMessage: Equatable, Codable { suggestedTitle: suggestedTitle, steps: steps, editAgentRounds: editAgentRounds, + parentTurnId: parentTurnId, codeReviewRound: codeReviewRound, fileEdits: fileEdits, turnStatus: turnStatus, - requestType: requestType + 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/ConversationServiceProvider.swift b/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift index 41c5a5ec..bb2b0573 100644 --- a/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift +++ b/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift @@ -4,13 +4,14 @@ 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]? @@ -22,13 +23,14 @@ public protocol ConversationServiceType { } 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]? @@ -344,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 @@ -360,6 +363,7 @@ public struct ConversationRequest { modelProviderName: String? = nil, turns: [TurnSchema] = [], agentMode: Bool = false, + customChatModeId: String? = nil, userLanguage: String?, turnId: String? = nil ) { @@ -375,6 +379,7 @@ public struct ConversationRequest { self.modelProviderName = modelProviderName self.turns = turns self.agentMode = agentMode + self.customChatModeId = customChatModeId self.userLanguage = userLanguage self.turnId = turnId } @@ -446,6 +451,18 @@ public struct ConversationProgressStep: Codable, Equatable, Identifiable { } } +public struct ContextSizeInfo: Codable, Equatable { + public let totalTokenLimit: Int + public let systemPromptTokens: Int + public let toolDefinitionTokens: Int + public let userMessagesTokens: Int + public let assistantMessagesTokens: Int + public let attachedFilesTokens: Int + public let toolResultsTokens: Int + public let totalUsedTokens: Int + public let utilizationPercentage: Double +} + public struct DidChangeWatchedFilesEvent: Codable { public var workspaceUri: String public var changes: [FileEvent] @@ -455,37 +472,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 901c6f81..f688777a 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 { @@ -23,6 +24,7 @@ public enum PromptTemplateScope: String, Codable, Equatable { case agentPanel = "agent-panel" case editor = "editor" case inline = "inline" + case inlineAgent = "inline-agent" case completion = "completion" } @@ -45,6 +47,7 @@ public struct CopilotModel: Codable, Equatable { public let isChatFallback: Bool public let capabilities: CopilotModelCapabilities public let billing: CopilotModelBilling? + public let degradationReason: String? } public struct CopilotModelPolicy: Codable, Equatable { @@ -70,6 +73,72 @@ public struct CopilotModelBilling: Codable, Equatable, Hashable { } } +// MARK: ChatModes +public enum ChatMode: String, Codable { + case Ask = "Ask" + case Edit = "Edit" + case Agent = "Agent" + case InlineAgent = "InlineAgent" +} + +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 +165,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 } } @@ -497,3 +577,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 6040f4be..8ee3e077 100644 --- a/Tool/Sources/ConversationServiceProvider/ToolNames.swift +++ b/Tool/Sources/ConversationServiceProvider/ToolNames.swift @@ -7,3 +7,16 @@ public enum ToolName: String, Codable { 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/CompressionHandler.swift b/Tool/Sources/GitHubCopilotService/Conversation/CompressionHandler.swift new file mode 100644 index 00000000..841d75d7 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/Conversation/CompressionHandler.swift @@ -0,0 +1,14 @@ +import Combine +import Foundation + +public protocol CompressionHandler { + var onCompressionStarted: PassthroughSubject { get } // conversationId + var onCompressionCompleted: PassthroughSubject { get } +} + +public final class CompressionHandlerImpl: CompressionHandler { + public static let shared = CompressionHandlerImpl() + + public var onCompressionStarted = PassthroughSubject() + public var onCompressionCompleted = PassthroughSubject() +} diff --git a/Tool/Sources/GitHubCopilotService/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/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 99e9b505..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 { diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift index 1396b998..4c8b2721 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 @@ -139,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) } @@ -221,6 +222,15 @@ class CopilotLocalProcessServer { case "copilot/mcpRuntimeLogs": notificationPublisher.send(anyNotification) return true + case "policy/didChange": + notificationPublisher.send(anyNotification) + return true + case "$/copilot/compressionStarted": + notificationPublisher.send(anyNotification) + return true + case "$/copilot/compressionCompleted": + notificationPublisher.send(anyNotification) + return true case "conversation/preconditionsNotification", "statusNotification": // Ignore return true @@ -262,13 +272,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) } } @@ -346,14 +360,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 bc0b017e..9533e367 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotMCPToolManager.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotMCPToolManager.swift @@ -28,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 diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift index 86a21bff..1f952728 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,79 @@ 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 autoCompress = UserDefaults.shared.value(for: \.autoCompress) + d["autoCompress"] = .bool(autoCompress) + + let state = UserDefaults.autoApproval.value(for: \.sensitiveFilesGlobalApprovals) + var autoApproveList: [JSONValue] = [] + for (key, rule) in state.rules { + 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 +177,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 +210,7 @@ enum GitHubCopilotRequest { .custom("checkStatus", .hash([:]), ClientRequest.NullHandler) } } - + struct CheckQuota: GitHubCopilotRequestType { typealias Response = GitHubCopilotQuotaInfo @@ -281,6 +356,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 +401,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 +432,7 @@ enum GitHubCopilotRequest { // MARK: Conversation struct CreateConversation: GitHubCopilotRequestType { - struct Response: Codable {} + typealias Response = ConversationCreateResponse var params: ConversationCreateParams @@ -342,7 +446,7 @@ enum GitHubCopilotRequest { // MARK: Conversation turn struct CreateTurn: GitHubCopilotRequestType { - struct Response: Codable {} + typealias Response = ConversationCreateResponse var params: TurnCreateParams @@ -352,12 +456,12 @@ enum GitHubCopilotRequest { return .custom("conversation/turn", dict, ClientRequest.NullHandler) } } - + 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([:]) @@ -378,12 +482,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 { @@ -393,6 +497,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 @@ -400,12 +520,12 @@ enum GitHubCopilotRequest { .custom("copilot/models", .hash([:]), ClientRequest.NullHandler) } } - + // MARK: MCP Tools - + struct UpdatedMCPToolsStatus: GitHubCopilotRequestType { typealias Response = Array - + var params: UpdateMCPToolsStatusParams var request: ClientRequest { @@ -414,43 +534,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 @@ -458,14 +578,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([:]) @@ -484,7 +604,7 @@ enum GitHubCopilotRequest { return .custom("conversation/registerTools", dict, ClientRequest.NullHandler) } } - + struct UpdateToolsStatus: GitHubCopilotRequestType { typealias Response = Array @@ -510,7 +630,7 @@ enum GitHubCopilotRequest { return .custom("conversation/copyCode", dict, ClientRequest.NullHandler) } } - + // MARK: Telemetry struct TelemetryException: GitHubCopilotRequestType { @@ -524,11 +644,12 @@ enum GitHubCopilotRequest { return .custom("telemetry/exception", dict, ClientRequest.NullHandler) } } - + // MARK: BYOK + struct BYOKSaveModel: GitHubCopilotRequestType { typealias Response = BYOKSaveModelResponse - + var params: BYOKSaveModelParams var request: ClientRequest { @@ -537,10 +658,10 @@ enum GitHubCopilotRequest { return .custom("copilot/byok/saveModel", dict, ClientRequest.NullHandler) } } - + struct BYOKDeleteModel: GitHubCopilotRequestType { typealias Response = BYOKDeleteModelResponse - + var params: BYOKDeleteModelParams var request: ClientRequest { @@ -549,10 +670,10 @@ enum GitHubCopilotRequest { return .custom("copilot/byok/deleteModel", dict, ClientRequest.NullHandler) } } - + struct BYOKListModels: GitHubCopilotRequestType { typealias Response = BYOKListModelsResponse - + var params: BYOKListModelsParams var request: ClientRequest { @@ -561,10 +682,10 @@ enum GitHubCopilotRequest { return .custom("copilot/byok/listModels", dict, ClientRequest.NullHandler) } } - + struct BYOKSaveApiKey: GitHubCopilotRequestType { typealias Response = BYOKSaveApiKeyResponse - + var params: BYOKSaveApiKeyParams var request: ClientRequest { @@ -573,10 +694,10 @@ enum GitHubCopilotRequest { return .custom("copilot/byok/saveApiKey", dict, ClientRequest.NullHandler) } } - + struct BYOKDeleteApiKey: GitHubCopilotRequestType { typealias Response = BYOKDeleteApiKeyResponse - + var params: BYOKDeleteApiKeyParams var request: ClientRequest { @@ -585,10 +706,10 @@ enum GitHubCopilotRequest { return .custom("copilot/byok/deleteApiKey", dict, ClientRequest.NullHandler) } } - + struct BYOKListApiKeys: GitHubCopilotRequestType { typealias Response = BYOKListApiKeysResponse - + var params: BYOKListApiKeysParams var request: ClientRequest { @@ -602,9 +723,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" @@ -613,13 +733,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 } } } @@ -633,7 +753,36 @@ public enum GitHubCopilotNotification { } } - + public enum CompressionTrigger: String, Codable { + case preTurn = "pre-turn" + case postToolCall = "post-tool-call" + case manual = "manual" + } + + public struct CompressionStartedNotification: Codable { + public var conversationId: String + public var partitionId: Int + public var reason: CompressionTrigger + + public static func decode(fromParams params: JSONValue?) -> CompressionStartedNotification? { + try? JSONDecoder().decode(Self.self, from: (try? JSONEncoder().encode(params)) ?? Data()) + } + } + + public struct CompressionCompletedNotification: Codable { + public var conversationId: String + public var archivedPartitionId: Int + public var newPartitionId: Int + public var summaryLength: Int + public var turnCount: Int + public var durationMs: Int + public var contextInfo: ContextSizeInfo? + + public static func decode(fromParams params: JSONValue?) -> CompressionCompletedNotification? { + try? JSONDecoder().decode(Self.self, from: (try? JSONEncoder().encode(params)) ?? Data()) + } + } + public struct MCPRuntimeNotification: Codable { public enum MCPRuntimeLogLevel: String, Codable { case Info = "info" @@ -646,10 +795,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 370cab55..85d199b2 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 -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)) - } - } -} - -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,8 @@ public struct ConversationProgressReport: BaseConversationProgress { public let references: [FileReference]? public let steps: [ConversationProgressStep]? public let editAgentRounds: [AgentRound]? + public let parentTurnId: String? + public let contextSize: ContextSizeInfo? } public struct ConversationProgressEnd: BaseConversationProgress { @@ -189,6 +93,8 @@ struct ConversationTemplatesParams: Codable { var workspaceFolders: [WorkspaceFolder]? } +typealias ConversationModesParams = ConversationTemplatesParams + // MARK: Conversation turn struct TurnCreateParams: Codable { var workDoneToken: String @@ -203,6 +109,7 @@ struct TurnCreateParams: Codable { var workspaceFolder: String? var workspaceFolders: [WorkspaceFolder]? var chatMode: String? + var customChatModeId: String? var needToolCallConfirmation: Bool? } @@ -242,6 +149,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 fb3cccd9..17792a88 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCP.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCP.swift @@ -156,11 +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 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 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 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 90abc560..fd1c8bf6 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCPRegistry.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCPRegistry.swift @@ -3,37 +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? - - 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, source, id, subfolder - } -} - -// MARK: - Server Status +// MARK: - Inputs -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 } @@ -41,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? @@ -53,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? @@ -75,46 +48,94 @@ 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, Hashable { public let type: ArgumentType = .positional public let description: String? @@ -123,21 +144,17 @@ public struct PositionalArgument: ArgumentProtocol, Hashable { 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" } - // Implement Hashable public func hash(into hasher: inout Hasher) { hasher.combine(type) hasher.combine(description) @@ -146,6 +163,7 @@ public struct PositionalArgument: ArgumentProtocol, Hashable { hasher.combine(value) hasher.combine(isSecret) hasher.combine(defaultValue) + hasher.combine(placeholder) hasher.combine(choices) hasher.combine(valueHint) hasher.combine(isRepeated) @@ -159,35 +177,32 @@ public struct PositionalArgument: ArgumentProtocol, Hashable { 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, Hashable { public let type: ArgumentType = .named - public let name: String? + 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 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" } - + public func hash(into hasher: inout Hasher) { hasher.combine(type) hasher.combine(name) @@ -197,6 +212,7 @@ public struct NamedArgument: ArgumentProtocol, Hashable { hasher.combine(value) hasher.combine(isSecret) hasher.combine(defaultValue) + hasher.combine(placeholder) hasher.combine(choices) hasher.combine(isRepeated) } @@ -210,13 +226,12 @@ public struct NamedArgument: ArgumentProtocol, Hashable { lhs.value == rhs.value && lhs.isSecret == rhs.isSecret && lhs.defaultValue == rhs.defaultValue && + lhs.placeholder == rhs.placeholder && lhs.choices == rhs.choices && lhs.isRepeated == rhs.isRepeated } } -// MARK: - Argument Enum - public enum Argument: Codable, Hashable { case positional(PositionalArgument) case named(NamedArgument) @@ -246,231 +261,289 @@ public enum Argument: Codable, Hashable { } } -// MARK: - KeyValueInput +// MARK: - Transport -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 choices: [String]? - public let variables: [String: Input]? +public enum TransportType: String, Codable { + case streamableHttp = "streamable-http" + case stdio = "stdio" + case sse = "sse" - public init( - name: String, - description: String?, - isRequired: Bool?, - format: ArgumentFormat?, - value: String?, - isSecret: Bool?, - defaultValue: 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.choices = choices - self.variables = variables + 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 } - - // Implement Hashable + 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(choices) - // Note: variables is excluded as Input would also need to be Hashable + hasher.combine(type) } - - 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.choices == rhs.choices - // Note: variables is excluded as Input would also need to be Hashable + + 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, Hashable { - public let registryType: String? - public let registryBaseURL: String? - public let identifier: String? + 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]? public init( - registryType: String?, - registryBaseURL: String?, - identifier: String?, + registryType: String, + registryBaseUrl: String?, + identifier: String, version: String?, - fileSHA256: String?, + fileSha256: String?, runtimeHint: String?, + transport: Transport, runtimeArguments: [Argument]?, packageArguments: [Argument]?, environmentVariables: [KeyValueInput]? ) { self.registryType = registryType - self.registryBaseURL = registryBaseURL + self.registryBaseUrl = registryBaseUrl self.identifier = identifier self.version = version - self.fileSHA256 = fileSHA256 + self.fileSha256 = fileSha256 self.runtimeHint = runtimeHint + self.transport = transport self.runtimeArguments = runtimeArguments self.packageArguments = packageArguments self.environmentVariables = environmentVariables } +} - 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" - } +// MARK: - Icons + +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" } -// MARK: - Transport Type +public enum IconTheme: String, Codable { + case light, dark +} -public enum TransportType: String, Codable { - case streamableHttp = "streamable-http" - case http = "http" - case sse = "sse" - - public var displayText: String { - switch self { - case .streamableHttp: - return "Streamable HTTP" - case .http: - return "HTTP" - case .sse: - return "SSE" - } - } +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, Hashable { - public let transportType: TransportType +public struct Repository: Codable { public let url: String - public let headers: [KeyValueInput]? - - public init( - transportType: TransportType, - url: String, - headers: [KeyValueInput]? - ) { - self.transportType = transportType - self.url = url - self.headers = headers - } + public let source: String + public let id: String? + public let subfolder: String? - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - - // Try "transport_type" first, then fall back to "type" - transportType = try container.decodeIfPresent(TransportType.self, forKey: .transportTypePreferred) - ?? container.decode(TransportType.self, forKey: .transportType) - - url = try container.decode(String.self, forKey: .url) - headers = try container.decodeIfPresent([KeyValueInput].self, forKey: .headers) - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(transportType, forKey: .transportTypePreferred) - try container.encode(url, forKey: .url) - try container.encodeIfPresent(headers, forKey: .headers) + 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 transportTypePreferred = "transport_type" + case url, source, id, subfolder } } -// MARK: - Publisher Provided Meta +// MARK: - Meta -public struct PublisherProvidedMeta: Codable { - public let tool: String? - public let version: String? - public let buildInfo: BuildInfo? - private let additionalProperties: [String: AnyCodable]? +public enum ServerStatus: String, Codable { + case active + case deprecated + case deleted +} - enum CodingKeys: String, CodingKey { - case tool, version - case buildInfo = "build_info" +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 } +} + +public struct PublisherProvidedMeta: Codable { + private let additionalProperties: [String: AnyCodable]? public init( - tool: String?, - version: String?, - buildInfo: BuildInfo?, additionalProperties: [String: AnyCodable]? = nil ) { - self.tool = tool - self.version = version - self.buildInfo = buildInfo 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 { @@ -480,85 +553,43 @@ public struct PublisherProvidedMeta: Codable { } } -public struct BuildInfo: Codable { - public let commit: String? - public let timestamp: String? - public let pipelineID: String? - - public init(commit: String?, timestamp: String?, pipelineID: String?) { - self.commit = commit - self.timestamp = timestamp - self.pipelineID = pipelineID - } +public struct MCPRegistryExtensionMeta: Codable { + public let publisherProvided: PublisherProvidedMeta? enum CodingKeys: String, CodingKey { - case commit, timestamp - case pipelineID = "pipeline_id" - } -} - -// MARK: - Official Meta - -public struct OfficialMeta: Codable { - public let id: String - public let publishedAt: String - public let updatedAt: String - public let isLatest: Bool - - public init( - id: String, - publishedAt: String, - updatedAt: String, - isLatest: Bool - ) { - self.id = id - self.publishedAt = publishedAt - self.updatedAt = updatedAt - self.isLatest = isLatest + case publisherProvided = "io.modelcontextprotocol.registry/publisher-provided" } - enum CodingKeys: String, CodingKey { - case id - case publishedAt = "published_at" - case updatedAt = "updated_at" - case isLatest = "is_latest" + public init(publisherProvided: PublisherProvidedMeta?) { + self.publisherProvided = publisherProvided } } -// MARK: - Server Meta - 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( - publisherProvided: PublisherProvidedMeta?, - official: OfficialMeta?, + official: OfficialMeta? = nil, additionalProperties: [String: AnyCodable]? = nil ) { - self.publisherProvided = publisherProvided 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) } } @@ -567,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 { @@ -579,107 +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, title, repository, version, packages, remotes, websiteUrl, icons + case schemaURL = "$schema" + case meta = "_meta" + } + public init( name: String, description: String, - status: ServerStatus?, + title: String?, repository: Repository?, version: String, - websiteURL: String?, - createdAt: String?, - updatedAt: String?, + websiteUrl: String?, + icons: [Icon]?, schemaURL: String?, packages: [Package]?, remotes: [Remote]?, - meta: ServerMeta? + meta: MCPRegistryExtensionMeta? ) { self.name = name self.description = description - self.status = status + self.title = title self.repository = repository self.version = version - self.websiteURL = websiteURL - self.createdAt = createdAt - self.updatedAt = updatedAt + self.websiteUrl = websiteUrl + self.icons = icons self.schemaURL = schemaURL self.packages = packages self.remotes = remotes self.meta = meta } - 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 schemaURL = "$schema" - case 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 } } @@ -694,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/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 8e5b21c1..9770d70d 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,12 +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] @@ -149,6 +164,14 @@ 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") + static let githubCopilotAgentAutoCompressDidChange = Notification + .Name("com.github.CopilotForXcode.GithubCopilotAgentAutoCompressDidChange") } public class GitHubCopilotBaseService { @@ -194,6 +217,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 @@ -274,6 +298,8 @@ public class GitHubCopilotBaseService { "watchedFiles": watchedFiles, "didChangeFeatureFlags": true, "stateDatabase": true, + "subAgent": JSONValue(booleanLiteral: enableSubagent), + "mcpAllowlist": true, ], "githubAppId": authAppId.map(JSONValue.string) ?? .null, ], @@ -423,11 +449,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) @@ -439,14 +464,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 != "/" { @@ -515,7 +544,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, @@ -562,36 +591,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 } } @@ -600,21 +605,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 { @@ -644,10 +687,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)") @@ -656,18 +700,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, @@ -681,8 +728,9 @@ 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)") @@ -712,6 +760,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] { @@ -864,6 +925,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 { @@ -871,6 +937,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 { @@ -905,20 +978,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 ) ) ) @@ -1254,6 +1325,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), @@ -1280,69 +1398,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) } } @@ -1381,7 +1439,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 != "/" { @@ -1396,9 +1462,34 @@ 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(), + DistributedNotificationCenter.default() + .publisher(for: .githubCopilotAgentAutoCompressDidChange) + .map { _ in "agentAutoCompress" } + .eraseToAnyPublisher() ) for await _ in combinedNotifications.values { diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/ServerNotificationHandler.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/ServerNotificationHandler.swift index 39c2c4a5..c1cf94a5 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/ServerNotificationHandler.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/ServerNotificationHandler.swift @@ -13,6 +13,8 @@ class ServerNotificationHandlerImpl: ServerNotificationHandler { var protocolProgressSubject: PassthroughSubject var conversationProgressHandler: ConversationProgressHandler = ConversationProgressHandlerImpl.shared var featureFlagNotifier: FeatureFlagNotifier = FeatureFlagNotifierImpl.shared + var copilotPolicyNotifier: CopilotPolicyNotifier = CopilotPolicyNotifierImpl.shared + var compressionHandler: CompressionHandler = CompressionHandlerImpl.shared init() { self.protocolProgressSubject = PassthroughSubject() @@ -44,6 +46,27 @@ 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 + case "$/copilot/compressionStarted": + if let payload = GitHubCopilotNotification.CompressionStartedNotification + .decode(fromParams: notification.params) { + compressionHandler.onCompressionStarted.send(payload.conversationId) + } + break + case "$/copilot/compressionCompleted": + if let payload = GitHubCopilotNotification.CompressionCompletedNotification + .decode(fromParams: notification.params) { + compressionHandler.onCompressionCompleted.send(payload) + } + break default: break } diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/ServerRequestHandler.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/ServerRequestHandler.swift index 40c96729..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 @@ -13,11 +13,12 @@ protocol ServerRequestHandler { 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 dynamicOAuthRequestHandler: DynamicOAuthRequestHandler = DynamicOAuthRequestHandlerImpl.shared func handleRequest(id: JSONId, _ request: ServerRequest, workspaceURL: URL, service: GitHubCopilotService?) { switch request { @@ -26,7 +27,7 @@ class ServerRequestHandlerImpl : ServerRequestHandler { do { let paramsData = try JSONEncoder().encode(params) let showMessageRequestParams = try JSONDecoder().decode(ShowMessageRequestParams.self, from: paramsData) - + showMessageRequestHandler.handleShowMessageRequest( ShowMessageRequest( id: id, @@ -41,7 +42,7 @@ class ServerRequestHandlerImpl : ServerRequestHandler { } } } - + case let .custom(method, params, callback): let legacyResponseHandler = toLegacyResponseHandler(callback) do { @@ -51,8 +52,9 @@ class ServerRequestHandlerImpl : ServerRequestHandler { let contextParams = try JSONDecoder().decode(ConversationContextParams.self, from: paramsData) conversationContextHandler.handleConversationContext( ConversationContextRequest(id: id, method: method, params: contextParams), - completion: legacyResponseHandler) - + completion: legacyResponseHandler + ) + case "copilot/watchedFiles": let paramsData = try JSONEncoder().encode(params) let watchedFilesParams = try JSONDecoder().decode(WatchedFilesParams.self, from: paramsData) @@ -60,21 +62,32 @@ class ServerRequestHandlerImpl : ServerRequestHandler { WatchedFilesRequest(id: id, method: method, params: watchedFilesParams), workspaceURL: workspaceURL, completion: legacyResponseHandler, - service: service) + service: service + ) 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) + 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) + 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 @@ -82,12 +95,12 @@ class ServerRequestHandlerImpl : ServerRequestHandler { } catch { handleError(id: id, method: method, error: error, callback: legacyResponseHandler) } - + default: break } } - + private func handleError(id: JSONId, method: String, error: Error, callback: @escaping (AnyJSONRPCResponse) -> Void) { callback( AnyJSONRPCResponse( @@ -95,14 +108,14 @@ class ServerRequestHandlerImpl : ServerRequestHandler { result: JSONValue.array([ JSONValue.null, JSONValue.hash([ - "code": .number(-32602/* Invalid params */), - "message": .string("Error handling \(method): \(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 8879b677..4153e1ce 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,8 @@ 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 { @@ -107,6 +113,15 @@ 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 isCustomAgentEnabled = CopilotPolicyNotifierImpl.shared.copilotPolicy.customAgentEnabled + let workspaceFolders = 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 } 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..2274c13d 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 { @@ -39,24 +45,21 @@ public func launchHostAppSettings() throws { let activated = hostApp.activate(options: [.activateIgnoringOtherApps]) Logger.ui.info("\(hostAppName()) activated: \(activated)") - let scriptSuccess = tryLaunchWithAppleScript() - - // If AppleScript fails, fall back to notification center - if !scriptSuccess { - DistributedNotificationCenter.default().postNotificationName( - .openSettingsWindowRequest, - object: nil - ) - Logger.ui.info("\(hostAppName()) settings notification sent after activation") - return - } + _ = tryLaunchWithAppleScript() + + DistributedNotificationCenter.default().postNotificationName( + .openSettingsWindowRequest, + object: nil + ) + Logger.ui.info("\(hostAppName()) settings notification sent after activation") + return } else { // If app is not running, launch it with the settings flag try launchHostAppWithArgs(args: ["--settings"]) } } -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 +71,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 +88,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 +129,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 +216,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 16298ebf..400c07e3 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") @@ -324,6 +332,14 @@ public extension UserDefaultPreferenceKeys { var suppressRestoreCheckpointConfirmation: PreferenceKey { .init(defaultValue: false, key: "SuppressRestoreCheckpointConfirmation") } + + var enableSubagent: PreferenceKey { + .init(defaultValue: true, key: "EnableSubagent") + } + + var autoCompress: PreferenceKey { + .init(defaultValue: true, key: "AutoCompress") + } } // MARK: - Theme @@ -604,11 +620,35 @@ public extension UserDefaultPreferenceKeys { .init(defaultValue: "", key: "CurrentUserName") } - var mcpRegistryURL: PreferenceKey { - .init(defaultValue: "https://api.mcp.github.com/2025-09-15/v0/servers", key: "MCPRegistryURL") + var mcpRegistryBaseURL: PreferenceKey { + .init(defaultValue: "https://api.mcp.github.com", key: "MCPRegistryBaseURL") } - var mcpRegistryURLHistory: PreferenceKey<[String]> { - .init(defaultValue: [], key: "MCPRegistryURLHistory") + 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 62a4a19d..9ab89738 100644 --- a/Tool/Sources/SharedUIComponents/Base/Colors.swift +++ b/Tool/Sources/SharedUIComponents/Base/Colors.swift @@ -6,4 +6,40 @@ public extension Color { 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/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/ConditionalFontWeight.swift b/Tool/Sources/SharedUIComponents/ConditionalFontWeight.swift index 55cc15c7..b09c94ba 100644 --- a/Tool/Sources/SharedUIComponents/ConditionalFontWeight.swift +++ b/Tool/Sources/SharedUIComponents/ConditionalFontWeight.swift @@ -8,11 +8,7 @@ public struct ConditionalFontWeight: ViewModifier { } public func body(content: Content) -> some View { - if #available(macOS 13.0, *), weight != nil { - content.fontWeight(weight) - } else { - content - } + content.fontWeight(weight) } } diff --git a/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/CustomScrollView.swift b/Tool/Sources/SharedUIComponents/CustomScrollView.swift index 0eb486f0..91c73fae 100644 --- a/Tool/Sources/SharedUIComponents/CustomScrollView.swift +++ b/Tool/Sources/SharedUIComponents/CustomScrollView.swift @@ -44,11 +44,7 @@ public struct CustomScrollView: View { } .listStyle(.plain) .modify { view in - if #available(macOS 13.0, *) { - view.listRowSeparator(.hidden).listSectionSeparator(.hidden) - } else { - view - } + view.listRowSeparator(.hidden).listSectionSeparator(.hidden) } .frame(idealHeight: max(10, height)) .onPreferenceChange(CustomScrollViewHeightPreferenceKey.self) { newHeight in diff --git a/Tool/Sources/SharedUIComponents/FontPicker.swift b/Tool/Sources/SharedUIComponents/FontPicker.swift index 2f91c9d0..cc2f4f4a 100644 --- a/Tool/Sources/SharedUIComponents/FontPicker.swift +++ b/Tool/Sources/SharedUIComponents/FontPicker.swift @@ -14,17 +14,10 @@ public struct FontPicker: View { } public var body: some View { - if #available(macOS 13.0, *) { - LabeledContent { - button - } label: { - label - } - } else { - HStack { - label - button - } + LabeledContent { + button + } label: { + label } } diff --git a/Tool/Sources/SharedUIComponents/SplitButton.swift b/Tool/Sources/SharedUIComponents/SplitButton.swift new file mode 100644 index 00000000..b3388850 --- /dev/null +++ b/Tool/Sources/SharedUIComponents/SplitButton.swift @@ -0,0 +1,288 @@ +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) + } +} + +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 + +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) + } + } +} + +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() + } + } + } +} + +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/StatusBarItemView/QuotaView.swift b/Tool/Sources/StatusBarItemView/QuotaView.swift index 2e85bc8c..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) } 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/Toast/Toast.swift b/Tool/Sources/Toast/Toast.swift index 704af7df..bb989885 100644 --- a/Tool/Sources/Toast/Toast.swift +++ b/Tool/Sources/Toast/Toast.swift @@ -265,28 +265,11 @@ public extension NSWorkspace { /// Opens the System Preferences/Settings app at the Extensions pane /// - Parameter extensionPointIdentifier: Optional identifier for specific extension type static func openExtensionsPreferences(extensionPointIdentifier: String? = nil) { - if #available(macOS 13.0, *) { - var urlString = "x-apple.systempreferences:com.apple.ExtensionsPreferences" - if let extensionPointIdentifier = extensionPointIdentifier { - urlString += "?extensionPointIdentifier=\(extensionPointIdentifier)" - } - NSWorkspace.shared.open(URL(string: urlString)!) - } else { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/open") - process.arguments = [ - "-b", - "com.apple.systempreferences", - "/System/Library/PreferencePanes/Extensions.prefPane" - ] - - do { - try process.run() - } catch { - // Handle error silently - return - } + var urlString = "x-apple.systempreferences:com.apple.ExtensionsPreferences" + if let extensionPointIdentifier = extensionPointIdentifier { + urlString += "?extensionPointIdentifier=\(extensionPointIdentifier)" } + NSWorkspace.shared.open(URL(string: urlString)!) } /// Opens the Xcode Extensions preferences directly diff --git a/Tool/Sources/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/XPCCommunicationBridge.swift b/Tool/Sources/XPCShared/XPCCommunicationBridge.swift index 4b7d09cb..d54976d4 100644 --- a/Tool/Sources/XPCShared/XPCCommunicationBridge.swift +++ b/Tool/Sources/XPCShared/XPCCommunicationBridge.swift @@ -80,7 +80,6 @@ extension XPCCommunicationBridge { } } -@available(macOS 13.0, *) public func showBackgroundPermissionAlert() { let alert = NSAlert() alert.messageText = "Background Permission Required" diff --git a/Tool/Sources/XPCShared/XPCExtensionService.swift b/Tool/Sources/XPCShared/XPCExtensionService.swift index 6957e8a0..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 @@ -118,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? @@ -127,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? @@ -158,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 } @@ -391,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) @@ -512,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 @@ -556,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 { @@ -583,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 d114844d..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,14 +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..8c93c60b 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, @@ -326,15 +326,13 @@ public final class XcodeInspector: ObservableObject { .value(for: \.restartXcodeInspectorIfAccessibilityAPIIsMalfunctioning) { let malfunctionCheck = Task { @XcodeInspectorActor [weak self] in - if #available(macOS 13.0, *) { - let notifications = await xcode.axNotifications.notifications().filter { - $0.kind == .uiElementDestroyed - }.debounce(for: .milliseconds(1000)) - for await _ in notifications { - guard let self else { return } - try Task.checkCancellation() - self.checkForAccessibilityMalfunction("Element Destroyed") - } + let notifications = await xcode.axNotifications.notifications().filter { + $0.kind == .uiElementDestroyed + }.debounce(for: .milliseconds(1000)) + for await _ in notifications { + guard let self else { return } + try Task.checkCancellation() + self.checkForAccessibilityMalfunction("Element Destroyed") } } @@ -374,7 +372,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") } }