diff --git a/.github/workflows/auto-close-pr.yml b/.github/workflows/auto-close-pr.yml index 752e32b3..90beda84 100644 --- a/.github/workflows/auto-close-pr.yml +++ b/.github/workflows/auto-close-pr.yml @@ -15,6 +15,7 @@ jobs: "At the moment we are not accepting contributions to the repository. Feedback for GitHub Copilot for Xcode can be given in the [Copilot community discussions](https://github.com/github/CopilotForXcode/discussions)." + if: ${{ !(startsWith(github.head_ref, 'release/') && github.event.pull_request.head.repo.full_name == github.repository) }} env: GH_REPO: ${{ github.repository }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/auto-create-release-pr.yml b/.github/workflows/auto-create-release-pr.yml new file mode 100644 index 00000000..dc6995b0 --- /dev/null +++ b/.github/workflows/auto-create-release-pr.yml @@ -0,0 +1,72 @@ +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: Wait for required checks + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + for i in $(seq 1 60); do + other_checks=$(gh pr checks "${{ github.ref_name }}" | grep -v "create-pr" || true) + if echo "$other_checks" | grep -wq "fail"; then + echo "Required checks failed." + exit 1 + fi + if [ -z "$other_checks" ]; then + echo "No other checks found yet, waiting..." + elif ! echo "$other_checks" | grep -wq "pending"; then + echo "All required checks passed." + exit 0 + fi + echo "Waiting for checks..." + sleep 10 + done + echo "Timed out waiting for required checks." + exit 1 + + - name: Merge pull request + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh pr merge "${{ github.ref_name }}" \ + --merge \ + --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 4e615b31..42463f66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,54 @@ 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.50.0 - May 20, 2026 +### Added +- Reasoning effort control for supported models: Low, Medium, or High from the model picker to balance response speed and quality. +- Added internal support for upcoming usage-based billing, including billing updates for the usage panel, usage notifications, and model picker. This will be visible to the user once usage-based billing rolls out. + +### Changed +- Bring Your Own Key (BYOK) is now generally available. + +## 0.49.0 - May 15, 2026 +### Added +- Native Anthropic Messages API (`/v1/messages`) endpoint support. +- Thinking support in chat for reasoning-capable models. +- Enhanced rate limit notifications and error messages. + +### Changed +- Refined tool call item UI in agent progress: removed border and divider, repositioned chevron, and adjusted spacing for better readability. +- Updated Copilot language server to 1.465.5. + +## 0.48.0 - April 23, 2026 +### Added +- Context window usage details in chat, including a token breakdown for system instructions, messages, attached files, and tool results. +- Auto Compress setting to compact conversation history and save context tokens. +- Install flow for Xcode's built-in MCP server from settings. + +### Changed +- Custom agents and the Auto model are now generally available. +- Removed support for macOS 12. +- Improved UI for model picker tooltips. + +### Fixed +- Fixed an issue where GPT-5.4 requests could return a 400 error. +- Fixed an issue where the MCP allowlist did not work correctly. + +## 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. diff --git a/CommunicationBridge/ServiceDelegate.swift b/CommunicationBridge/ServiceDelegate.swift index 4e289e57..6dbb0e0f 100644 --- a/CommunicationBridge/ServiceDelegate.swift +++ b/CommunicationBridge/ServiceDelegate.swift @@ -175,7 +175,7 @@ actor ExtensionServiceLauncher { return configuration }() ) { app, error in - if let error = error { + if error != nil { continuation.resume(returning: nil) } else { continuation.resume(returning: app) diff --git a/Copilot for Xcode.xcodeproj/project.pbxproj b/Copilot for Xcode.xcodeproj/project.pbxproj index c762e625..d232491a 100644 --- a/Copilot for Xcode.xcodeproj/project.pbxproj +++ b/Copilot for Xcode.xcodeproj/project.pbxproj @@ -845,7 +845,7 @@ "@executable_path/../Frameworks", "@executable_path/../../../../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = "$(APP_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).EditorExtension"; PRODUCT_NAME = Copilot; @@ -874,7 +874,7 @@ "@executable_path/../Frameworks", "@executable_path/../../../../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = "$(APP_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).EditorExtension"; PRODUCT_NAME = Copilot; @@ -936,7 +936,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -991,7 +991,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = macosx; @@ -1022,7 +1022,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = "$(APP_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE)"; PRODUCT_MODULE_NAME = Copilot_for_Xcode; @@ -1056,7 +1056,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = "$(APP_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE)"; PRODUCT_NAME = "$(HOST_APP_NAME)"; @@ -1072,7 +1072,7 @@ CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = VEKTX9H2N7; ENABLE_HARDENED_RUNTIME = YES; - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_VERSION = 5.0; @@ -1087,7 +1087,7 @@ DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=macosx*]" = VEKTX9H2N7; ENABLE_HARDENED_RUNTIME = YES; - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; @@ -1117,7 +1117,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = "$(APP_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).ExtensionService"; PRODUCT_NAME = "$(EXTENSION_SERVICE_NAME)"; @@ -1151,7 +1151,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = "$(APP_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).ExtensionService"; PRODUCT_NAME = "$(EXTENSION_SERVICE_NAME)"; @@ -1172,7 +1172,7 @@ ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).CommunicationBridge"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -1193,7 +1193,7 @@ ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).CommunicationBridge"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/Copilot for Xcode.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 5f28f89b..d45adb9f 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 + } } } } @@ -134,7 +144,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Start cleanup in background without waiting Task { - let quitTask = Task { + _ = Task { let service = try? getService() try? await service?.quitService() } @@ -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, @@ -247,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 faf0c12b..733fbe02 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 @@ -73,6 +75,7 @@ let package = Package( dependencies: [ "SuggestionWidget", "SuggestionService", + "SuggestionInjector", "ChatService", "PromptToCodeService", "ConversationTab", @@ -121,6 +124,7 @@ let package = Package( "Client", "LaunchAgentManager", "GitHubCopilotViewModel", + "UpdateChecker", .product(name: "SuggestionProvider", package: "Tool"), .product(name: "Toast", package: "Tool"), .product(name: "SharedUIComponents", package: "Tool"), @@ -132,6 +136,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 @@ -185,7 +190,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", @@ -196,6 +204,7 @@ let package = Package( name: "ConversationTab", dependencies: [ "ChatService", + "GitHubCopilotViewModel", .product(name: "SharedUIComponents", package: "Tool"), .product(name: "ChatAPIService", package: "Tool"), .product(name: "Logger", package: "Tool"), @@ -214,10 +223,12 @@ let package = Package( .target( name: "SuggestionWidget", dependencies: [ + "ChatService", "PromptToCodeService", "ConversationTab", "GitHubCopilotViewModel", "PersistMiddleware", + .product(name: "CGEventOverride", package: "CGEventOverride"), .product(name: "GitHubCopilotService", package: "Tool"), .product(name: "Toast", package: "Tool"), .product(name: "UserDefaultsObserver", package: "Tool"), @@ -258,6 +269,7 @@ let package = Package( .product(name: "GitHubCopilotService", package: "Tool"), .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), .product(name: "Status", package: "Tool"), + .product(name: "Logger", package: "Tool"), ] ), diff --git a/Core/Sources/ChatService/ChatService.swift b/Core/Sources/ChatService/ChatService.swift index f88a349f..e90faa3c 100644 --- a/Core/Sources/ChatService/ChatService.swift +++ b/Core/Sources/ChatService/ChatService.swift @@ -30,6 +30,7 @@ public protocol ChatServiceType { references: [ConversationAttachedReference], model: String?, modelProviderName: String?, + reasoningEffort: String?, agentMode: Bool, customChatModeId: String?, userLanguage: String?, @@ -64,11 +65,14 @@ public final class ChatService: ChatServiceType, ObservableObject { public var memory: ContextAwareAutoManagedChatMemory @Published public internal(set) var chatHistory: [ChatMessage] = [] @Published public internal(set) var isReceivingMessage = false + @Published public internal(set) var isSummarizingConversation = false @Published public internal(set) var fileEditMap: OrderedDictionary = [:] + @Published public internal(set) var contextSizeInfo: ContextSizeInfo? = nil public internal(set) var requestType: RequestType? = nil public private(set) var chatTabInfo: ChatTabInfo private let conversationProvider: ConversationServiceProvider? private let conversationProgressHandler: ConversationProgressHandler + private let compressionHandler: CompressionHandler private let conversationContextHandler: ConversationContextHandler = ConversationContextHandlerImpl.shared // sync all the files in the workspace to watch for changes. private let watchedFilesHandler: WatchedFilesHandler = WatchedFilesHandlerImpl.shared @@ -81,14 +85,25 @@ public final class ChatService: ChatServiceType, ObservableObject { private var pendingToolCallRequests: [String: ToolCallRequest] = [:] // Workaround: toolConfirmation request does not have parent turnId private var conversationTurnTracking = ConversationTurnTrackingState() + + /// Single source of truth for an in-flight streaming thinking block. Sealed when the turn ends + /// or a non-thinking payload arrives. `clientEntryId` is stable across server delta `id` churn. + private struct ActiveThinkingCursor { + let clientEntryId: UUID + let targetMessageId: String + let originTurnId: String + } + private var activeThinking: ActiveThinkingCursor? = nil init(provider: any ConversationServiceProvider, memory: ContextAwareAutoManagedChatMemory = ContextAwareAutoManagedChatMemory(), conversationProgressHandler: ConversationProgressHandler = ConversationProgressHandlerImpl.shared, + compressionHandler: CompressionHandler = CompressionHandlerImpl.shared, chatTabInfo: ChatTabInfo) { self.memory = memory self.conversationProvider = provider self.conversationProgressHandler = conversationProgressHandler + self.compressionHandler = compressionHandler self.chatTabInfo = chatTabInfo memory.chatService = self @@ -134,6 +149,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() { @@ -149,30 +177,7 @@ public final class ChatService: ChatServiceType, ObservableObject { private func subscribeToClientToolConfirmationEvent() { ClientToolHandlerImpl.shared.onClientToolConfirmationEvent.sink(receiveValue: { [weak self] (request, completion) in - 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 - } - - let parentTurnId = self?.conversationTurnTracking.turnParentMap[params.turnId] - - let editAgentRounds: [AgentRound] = [ - AgentRound(roundId: params.roundId, - reply: "", - toolCalls: [ - AgentToolCall(id: params.toolCallId, name: params.name, status: .waitForConfirmation, invokeParams: params, title: params.title) - ] - ) - ] - self?.appendToolCallHistory(turnId: params.turnId, editAgentRounds: editAgentRounds, parentTurnId: parentTurnId) - 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) } @@ -246,14 +251,24 @@ public final class ChatService: ChatServiceType, ObservableObject { // this will be triggerred in conversation tab if needed public func restoreIfNeeded() { guard self.isRestored == false else { return } - + Task { - let storedChatMessages = fetchAllChatMessagesFromStorage() + var storedChatMessages = fetchAllChatMessagesFromStorage() + // Force-seal any thinking entries that were persisted mid-stream (e.g. app crashed + // before the seal sweep ran). Otherwise they'd render with the placeholder "Thinking" + // title forever. + for messageIndex in storedChatMessages.indices where storedChatMessages[messageIndex].role == .assistant { + for path in Self.allThinkingPaths(in: storedChatMessages[messageIndex]) { + Self.mutateThinking(at: path, in: &storedChatMessages[messageIndex]) { entry in + if !entry.isComplete { entry.isComplete = true } + } + } + } await mutateHistory { history in history.append(contentsOf: storedChatMessages) } } - + self.isRestored = true } @@ -298,9 +313,23 @@ public final class ChatService: ChatServiceType, ObservableObject { } // MARK: - Helper Methods for Tool Call Status Updates + + /// Returns true if the `conversationId` belongs to the active conversation or any subagent conversations. + func isConversationIdValid(_ conversationId: String) -> 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 - private func sendToolConfirmationResponse(_ request: ToolCallRequest, accepted: Bool) { + func sendToolConfirmationResponse(_ request: ToolCallRequest, accepted: Bool) { let toolResult = LanguageModelToolConfirmationResult( result: accepted ? .Accept : .Dismiss ) @@ -335,6 +364,7 @@ public final class ChatService: ChatServiceType, ObservableObject { references: [ConversationAttachedReference], model: String? = nil, modelProviderName: String? = nil, + reasoningEffort: String? = nil, agentMode: Bool = false, customChatModeId: String? = nil, userLanguage: String? = nil, @@ -441,6 +471,7 @@ public final class ChatService: ChatServiceType, ObservableObject { references: references, model: model, modelProviderName: modelProviderName, + reasoningEffort: reasoningEffort, agentMode: agentMode, customChatModeId: customChatModeId, userLanguage: userLanguage, @@ -450,8 +481,17 @@ public final class ChatService: ChatServiceType, ObservableObject { self.lastUserRequest = request self.skillSet = validSkillSet - if let response = try await sendConversationRequest(request) { - await handleConversationCreateResponse(response) + + 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 } } @@ -463,6 +503,7 @@ public final class ChatService: ChatServiceType, ObservableObject { references: [ConversationAttachedReference], model: String? = nil, modelProviderName: String? = nil, + reasoningEffort: String? = nil, agentMode: Bool = false, customChatModeId: String? = nil, userLanguage: String? = nil, @@ -489,6 +530,7 @@ public final class ChatService: ChatServiceType, ObservableObject { references: references, model: model, modelProviderName: modelProviderName, + reasoningEffort: reasoningEffort, agentMode: agentMode, customChatModeId: customChatModeId, userLanguage: userLanguage, @@ -500,7 +542,10 @@ public final class ChatService: ChatServiceType, ObservableObject { await memory.mutateHistory { history in if let index = history.firstIndex(where: { $0.id == response.turnId && $0.role.isAssistant }) { history[index].modelName = response.modelName + let modelProviderName = response.modelInfo?.providerName ?? response.modelProviderName + history[index].modelProviderName = modelProviderName history[index].billingMultiplier = response.billingMultiplier + history[index].reasoningEffort = response.modelInfo?.reasoningEffort self.saveChatMessageToStorage(history[index]) } @@ -745,7 +790,11 @@ public final class ChatService: ChatServiceType, ObservableObject { guard let workDownToken = activeRequestId, workDownToken == token else { return } - + + if let contextSize = progress.contextSize { + self.contextSizeInfo = contextSize + } + let id = progress.turnId var content = "" var references: [ConversationReference] = [] @@ -756,28 +805,68 @@ public final class ChatService: ChatServiceType, ObservableObject { if let reply = progress.reply { content = reply } - + if let progressReferences = progress.references, !progressReferences.isEmpty { references = progressReferences.toConversationReferences() } - + if let progressSteps = progress.steps, !progressSteps.isEmpty { steps = progressSteps } - + if let progressAgentRounds = progress.editAgentRounds, !progressAgentRounds.isEmpty { editAgentRounds = progressAgentRounds } - - if content.isEmpty && references.isEmpty && steps.isEmpty && editAgentRounds.isEmpty && parentTurnId == nil { + + let progressThinkingDelta = progress.thinking + let hasThinking = !(progressThinkingDelta?.text?.allSatisfy { $0.isEmpty } ?? true) + let hasNonThinking = !content.isEmpty || !references.isEmpty || !steps.isEmpty || !editAgentRounds.isEmpty + + // Resolve the in-flight cursor against this event. The cursor is sealed when the active + // turn changes, or when a non-thinking payload arrives signalling that reasoning has + // ended and the model is now speaking/acting. + if let cursor = activeThinking, cursor.originTurnId != id { + sealActiveThinking() + } + if !hasThinking, hasNonThinking, activeThinking != nil { + sealActiveThinking() + } + + if content.isEmpty && references.isEmpty && steps.isEmpty && editAgentRounds.isEmpty && parentTurnId == nil && !hasThinking { return } - + let messageContent = content let messageReferences = references let messageSteps = steps - let messageAgentRounds = editAgentRounds + var messageAgentRounds = editAgentRounds let messageParentTurnId = parentTurnId + var messageThinking: [MessageThinking] = [] + + if hasThinking, let progressThinkingDelta { + // Open a cursor on the first delta of a streaming block. Subsequent deltas reuse the + // same `clientEntryId` so `mergeThinking` concatenates into one entry even when the + // server's `id` changes mid-stream. + let cursor = activeThinking ?? { + let opened = ActiveThinkingCursor( + clientEntryId: UUID(), + targetMessageId: parentTurnId ?? id, + originTurnId: id + ) + activeThinking = opened + return opened + }() + let entry = MessageThinking(from: progressThinkingDelta, clientEntryId: cursor.clientEntryId) + // Route the entry: into the last agent round when this event carries one (mid-tool-loop + // reasoning, including sub-agent rounds), otherwise onto the message itself (pre-tool + // reasoning). For sub-agent events, ChatMemory.appendMessage's parent-turn merge will + // forward the round's thinking into the parent's last sub-round via `mergeThinking`. + if let lastIndex = messageAgentRounds.indices.last { + messageAgentRounds[lastIndex].thinking.append(entry) + } else { + messageThinking = [entry] + } + } Task { let message = ChatMessage( @@ -787,6 +876,7 @@ public final class ChatService: ChatServiceType, ObservableObject { references: messageReferences, steps: messageSteps, editAgentRounds: messageAgentRounds, + thinking: messageThinking, parentTurnId: messageParentTurnId, turnStatus: .inProgress ) @@ -795,8 +885,142 @@ public final class ChatService: ChatServiceType, ObservableObject { } } + /// Seals the cursor's entry: marks it `isComplete`, persists the owning message, and kicks off + /// the LSP title-generation request. Looking up by `clientEntryId` (set when the cursor was + /// opened) makes this independent of the server's per-delta `id` and of which location the + /// entry was routed to (top-level message, agent round, or sub-agent round). + private func sealActiveThinking() { + guard let cursor = activeThinking else { return } + activeThinking = nil + Task { + var sealedText: String? = nil + var sealedMessage: ChatMessage? = nil + await memory.mutateHistory { history in + guard let messageIndex = history.firstIndex(where: { $0.id == cursor.targetMessageId }), + history[messageIndex].role == .assistant, + let path = Self.findThinkingPath(clientEntryId: cursor.clientEntryId, in: history[messageIndex]) + else { return } + Self.mutateThinking(at: path, in: &history[messageIndex]) { entry in + guard !entry.isComplete else { return } + entry.isComplete = true + if let text = entry.text?.joined(), !text.isEmpty { + sealedText = text + } + } + sealedMessage = history[messageIndex] + } + if let sealedMessage { + saveChatMessageToStorage(sealedMessage) + } + guard let sealedText else { return } + await requestThinkingTitle(for: sealedText, cursor: cursor) + } + } + + private func requestThinkingTitle(for thinkingText: String, cursor: ActiveThinkingCursor) async { + let extractedTitles = MessageThinking.parseSections(from: thinkingText).compactMap { $0.title } + let params = GenerateThinkingTitleParams( + thinkingContent: extractedTitles.isEmpty ? thinkingText : nil, + extractedTitles: extractedTitles.isEmpty ? nil : extractedTitles + ) + do { + guard let response = try await conversationProvider?.generateThinkingTitle(params), + !response.title.isEmpty else { return } + let trimmed = response.title.trimmingCharacters(in: .whitespacesAndNewlines) + let title = trimmed.count > 80 ? String(trimmed.prefix(80)) + "\u{2026}" : trimmed + guard !title.isEmpty else { return } + var titledMessage: ChatMessage? = nil + await memory.mutateHistory { history in + guard let messageIndex = history.firstIndex(where: { $0.id == cursor.targetMessageId }), + history[messageIndex].role == .assistant, + let path = Self.findThinkingPath(clientEntryId: cursor.clientEntryId, in: history[messageIndex]) + else { return } + Self.mutateThinking(at: path, in: &history[messageIndex]) { $0.title = title } + titledMessage = history[messageIndex] + } + if let titledMessage { + saveChatMessageToStorage(titledMessage) + } + } catch { + Logger.gitHubCopilot.debug("Failed to generate thinking title: \(error)") + } + } + + /// Path to a `MessageThinking` entry inside an assistant `ChatMessage`. Covers the three + /// places thinking can live: top-level on the message, on an agent round, or on a sub-agent + /// round under an agent round. + private enum ThinkingPath { + case message(entryIndex: Int) + case round(roundIndex: Int, entryIndex: Int) + case subRound(roundIndex: Int, subRoundIndex: Int, entryIndex: Int) + } + + private static func findThinkingPath(clientEntryId: UUID, in message: ChatMessage) -> ThinkingPath? { + let predicate: (MessageThinking) -> Bool = { $0.clientEntryId == clientEntryId } + if let entryIndex = message.thinking.firstIndex(where: predicate) { + return .message(entryIndex: entryIndex) + } + for (roundIndex, round) in message.editAgentRounds.enumerated() { + if let entryIndex = round.thinking.firstIndex(where: predicate) { + return .round(roundIndex: roundIndex, entryIndex: entryIndex) + } + for (subRoundIndex, subRound) in (round.subAgentRounds ?? []).enumerated() { + if let entryIndex = subRound.thinking.firstIndex(where: predicate) { + return .subRound(roundIndex: roundIndex, subRoundIndex: subRoundIndex, entryIndex: entryIndex) + } + } + } + return nil + } + + /// All `ThinkingPath`s in the message, in stable visit order. Used by sweeps that need to + /// touch every entry without knowing the cursor's `clientEntryId`. + private static func allThinkingPaths(in message: ChatMessage) -> [ThinkingPath] { + var paths: [ThinkingPath] = [] + for entryIndex in message.thinking.indices { + paths.append(.message(entryIndex: entryIndex)) + } + for (roundIndex, round) in message.editAgentRounds.enumerated() { + for entryIndex in round.thinking.indices { + paths.append(.round(roundIndex: roundIndex, entryIndex: entryIndex)) + } + for (subRoundIndex, subRound) in (round.subAgentRounds ?? []).enumerated() { + for entryIndex in subRound.thinking.indices { + paths.append(.subRound(roundIndex: roundIndex, subRoundIndex: subRoundIndex, entryIndex: entryIndex)) + } + } + } + return paths + } + + private static func mutateThinking(at path: ThinkingPath, in message: inout ChatMessage, _ mutate: (inout MessageThinking) -> Void) { + switch path { + case .message(let entryIndex): + mutate(&message.thinking[entryIndex]) + case .round(let roundIndex, let entryIndex): + mutate(&message.editAgentRounds[roundIndex].thinking[entryIndex]) + case .subRound(let roundIndex, let subRoundIndex, let entryIndex): + guard var subRounds = message.editAgentRounds[roundIndex].subAgentRounds else { return } + mutate(&subRounds[subRoundIndex].thinking[entryIndex]) + message.editAgentRounds[roundIndex].subAgentRounds = subRounds + } + } + + private func strippingRequestIDs(from message: String) -> String { + // "Request ID:" always appears before "GitHub Request ID:", so cutting at the first + // occurrence removes both along with the preceding separator (". " or " | ") + guard let range = message.range(of: "Request ID:", options: .caseInsensitive) else { + return message + } + return String(message[.. 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) + } } @@ -1217,58 +1497,55 @@ extension ChatService { guard !isReceivingMessage else { activeRequestId = nil - throw CancellationError() + throw CancellationError() } isReceivingMessage = true requestType = .codeReview let turnId = UUID().uuidString - do { - await CodeReviewService.shared.resetComments() - - await addCodeReviewUserMessage(id: UUID().uuidString, turnId: turnId, group: group) - - let initialBotMessage = ChatMessage( - assistantMessageWithId: turnId, - chatTabID: chatTabInfo.id, - turnStatus: .inProgress, - requestType: .codeReview - ) - await memory.appendMessage(initialBotMessage) - - guard let projectRootURL = getProjectRootURL() - else { - let round = CodeReviewRound.fromError(turnId: turnId, error: "Invalid git repository.") - await appendCodeReviewRound(round) - resetOngoingRequest(with: .error) - return - } - - let prChanges = await CurrentChangeService.getPRChanges( - projectRootURL, - group: group, - shouldIncludeFile: shouldIncludeFileForReview - ) - guard !prChanges.isEmpty else { - let round = CodeReviewRound.fromError( - turnId: turnId, - error: group == .index ? "No staged changes found to review." : "No unstaged changes found to review." - ) - await appendCodeReviewRound(round) - resetOngoingRequest() - return - } - - let round: CodeReviewRound = .init( + await CodeReviewService.shared.resetComments() + + await addCodeReviewUserMessage(id: UUID().uuidString, turnId: turnId, group: group) + + let initialBotMessage = ChatMessage( + assistantMessageWithId: turnId, + chatTabID: chatTabInfo.id, + turnStatus: .inProgress, + requestType: .codeReview + ) + await memory.appendMessage(initialBotMessage) + + guard let projectRootURL = getProjectRootURL() + else { + let round = CodeReviewRound.fromError(turnId: turnId, error: "Invalid git repository.") + await appendCodeReviewRound(round) + resetOngoingRequest(with: .error) + return + } + + let prChanges = await CurrentChangeService.getPRChanges( + projectRootURL, + group: group, + shouldIncludeFile: shouldIncludeFileForReview + ) + guard !prChanges.isEmpty else { + let round = CodeReviewRound.fromError( turnId: turnId, - status: .waitForConfirmation, - request: .from(prChanges) + error: group == .index + ? "No staged changes found to review." + : "No unstaged changes found to review." ) - await appendCodeReviewRound(round, turnStatus: .waitForConfirmation) - } catch { - resetOngoingRequest(with: .error) - throw error + await appendCodeReviewRound(round) + resetOngoingRequest() + return } + + let round: CodeReviewRound = .init( + turnId: turnId, + status: .waitForConfirmation, + request: .from(prChanges) + ) + await appendCodeReviewRound(round, turnStatus: .waitForConfirmation) } private func shouldIncludeFileForReview(url: URL) -> Bool { diff --git a/Core/Sources/ChatService/ToolCalls/AutoApproval/AutoApprovalScope.swift b/Core/Sources/ChatService/ToolCalls/AutoApproval/AutoApprovalScope.swift new file mode 100644 index 00000000..8be738f6 --- /dev/null +++ b/Core/Sources/ChatService/ToolCalls/AutoApproval/AutoApprovalScope.swift @@ -0,0 +1,11 @@ +import Foundation + +public typealias ConversationID = String + +public enum AutoApprovalScope: Hashable, Sendable { + 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 b8f8be62..2eb6b160 100644 --- a/Core/Sources/ChatService/ToolCalls/InsertEditIntoFileTool.swift +++ b/Core/Sources/ChatService/ToolCalls/InsertEditIntoFileTool.swift @@ -250,7 +250,8 @@ extension InsertEditIntoFileTool { } if let focusedElement = XcodeInspector.shared.focusedElement, - focusedElement.isSourceEditor, + focusedElement.isNonNavigatorSourceEditor, + focusedElement.realtimeDocumentURL == fileURL, focusedElement.value != diskFileContent { throw InsertEditError.fileHasUnsavedChanges(fileURL) diff --git a/Core/Sources/ConversationTab/Chat.swift b/Core/Sources/ConversationTab/Chat.swift index fc3e2c58..4abf8187 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 { @@ -29,6 +30,7 @@ public struct DisplayedChatMessage: Equatable { public var suggestedTitle: String? = nil public var errorMessages: [String] = [] public var steps: [ConversationProgressStep] = [] + public var thinking: [MessageThinking] = [] public var editAgentRounds: [AgentRound] = [] public var parentTurnId: String? = nil public var panelMessages: [CopilotShowMessageParams] = [] @@ -37,7 +39,9 @@ public struct DisplayedChatMessage: Equatable { public var turnStatus: ChatMessage.TurnStatus? = nil public let requestType: RequestType public var modelName: String? = nil + public var modelProviderName: String? = nil public var billingMultiplier: Float? = nil + public var reasoningEffort: String? = nil public init( id: String, @@ -49,6 +53,7 @@ public struct DisplayedChatMessage: Equatable { suggestedTitle: String? = nil, errorMessages: [String] = [], steps: [ConversationProgressStep] = [], + thinking: [MessageThinking] = [], editAgentRounds: [AgentRound] = [], parentTurnId: String? = nil, panelMessages: [CopilotShowMessageParams] = [], @@ -57,7 +62,9 @@ public struct DisplayedChatMessage: Equatable { turnStatus: ChatMessage.TurnStatus? = nil, requestType: RequestType, modelName: String? = nil, - billingMultiplier: Float? = nil + modelProviderName: String? = nil, + billingMultiplier: Float? = nil, + reasoningEffort: String? = nil ) { self.id = id self.role = role @@ -68,6 +75,7 @@ public struct DisplayedChatMessage: Equatable { self.suggestedTitle = suggestedTitle self.errorMessages = errorMessages self.steps = steps + self.thinking = thinking self.editAgentRounds = editAgentRounds self.parentTurnId = parentTurnId self.panelMessages = panelMessages @@ -76,7 +84,9 @@ public struct DisplayedChatMessage: Equatable { self.turnStatus = turnStatus self.requestType = requestType self.modelName = modelName + self.modelProviderName = modelProviderName self.billingMultiplier = billingMultiplier + self.reasoningEffort = reasoningEffort } } @@ -294,16 +304,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] { @@ -453,11 +469,21 @@ struct Chat { set { conversation.isReceivingMessage = newValue } } + var isSummarizingConversation: Bool { + get { conversation.isSummarizingConversation } + set { conversation.isSummarizingConversation = newValue } + } + var requestType: RequestType? { get { conversation.requestType } set { conversation.requestType = newValue } } + var contextSizeInfo: ContextSizeInfo? { + get { conversation.contextSizeInfo } + set { conversation.contextSizeInfo = newValue } + } + var handOffClicked: Bool { get { editor.handOffClicked } set { editor.handOffClicked = newValue } @@ -581,6 +607,7 @@ struct Chat { case copyCode(MessageID) case insertCode(String) case toolCallAccepted(String) + case toolCallAcceptedWithApproval(String, ToolAutoApprovalManager.AutoApproval?) case toolCallCompleted(String, String) case toolCallCancelled(String) @@ -588,10 +615,12 @@ struct Chat { case observeHistoryChange case observeIsReceivingMessageChange case observeFileEditChange + case observeContextSizeInfoChange case historyChanged case isReceivingMessageChanged case fileEditChanged + case contextSizeInfoChanged case chatMenu(ChatMenu.Action) @@ -637,6 +666,8 @@ struct Chat { case undoCheckPoint // Revert the restore case discardCheckPoint case reloadWorkingset(DisplayedChatMessage) + + case openAutoApproveSettings } let service: ChatService @@ -647,6 +678,7 @@ struct Chat { case observeIsReceivingMessageChange(UUID) case sendMessage(UUID) case observeFileEditChange(UUID) + case observeContextSizeInfoChange(UUID) case observeFixErrorNotification(UUID) } @@ -713,6 +745,7 @@ struct Chat { let selectedModelFamily = selectedModel?.modelFamily ?? CopilotModelManager.getDefaultChatModel( scope: AppState.shared.modelScope() )?.modelFamily + let reasoningEffort = selectedModel.flatMap { AppState.shared.effectiveReasoningEffort(for: $0) } let agentMode = AppState.shared.isAgentModeEnabled() let selectedAgentSubMode = AppState.shared.getSelectedAgentSubMode() let shouldAttachImages = selectedModel?.supportVision ?? CopilotModelManager.getDefaultChatModel( @@ -749,6 +782,7 @@ struct Chat { references: references, model: selectedModelFamily, modelProviderName: selectedModel?.providerName, + reasoningEffort: reasoningEffort, agentMode: agentMode, customChatModeId: selectedAgentSubMode, userLanguage: chatResponseLocale @@ -760,6 +794,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 @@ -797,6 +842,7 @@ struct Chat { references: references, model: selectedModelFamily, modelProviderName: selectedModel?.providerName, + reasoningEffort: selectedModel.flatMap { AppState.shared.effectiveReasoningEffort(for: $0) }, agentMode: agentMode, customChatModeId: selectedAgentSubMode, userLanguage: chatResponseLocale @@ -927,6 +973,7 @@ struct Chat { await send(.observeHistoryChange) await send(.observeIsReceivingMessageChange) await send(.observeFileEditChange) + await send(.observeContextSizeInfoChange) } case .observeHistoryChange: @@ -952,6 +999,7 @@ struct Chat { return .run { send in let stream = AsyncStream { continuation in let cancellable = service.$isReceivingMessage + .merge(with: service.$isSummarizingConversation) .sink { _ in continuation.yield() } @@ -986,6 +1034,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]() @@ -1012,6 +1079,7 @@ struct Chat { suggestedTitle: message.suggestedTitle, errorMessages: message.errorMessages, steps: message.steps, + thinking: message.thinking, editAgentRounds: message.editAgentRounds, parentTurnId: message.parentTurnId, panelMessages: message.panelMessages, @@ -1020,7 +1088,9 @@ struct Chat { turnStatus: message.turnStatus, requestType: message.requestType, modelName: message.modelName, - billingMultiplier: message.billingMultiplier + modelProviderName: message.modelProviderName, + billingMultiplier: message.billingMultiplier, + reasoningEffort: message.reasoningEffort )) return all @@ -1030,9 +1100,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 @@ -1170,7 +1245,7 @@ struct Chat { return .none // MARK: - Code Review - case let .codeReview(.request(group)): + case .codeReview(.request(_)): return .run { send in await send(.discardCheckPoint) } @@ -1264,7 +1339,7 @@ struct Chat { // TODO: if we need to switch to agent mode or keep the current mode let selectedAgentSubMode = AppState.shared.getSelectedAgentSubMode() - return .run { _ in + return .run { _ in try await service.send( UUID().uuidString, content: message, @@ -1272,6 +1347,7 @@ struct Chat { references: references, model: selectedModelFamily, modelProviderName: selectedModel?.providerName, + reasoningEffort: selectedModel.flatMap { AppState.shared.effectiveReasoningEffort(for: $0) }, agentMode: agentMode, customChatModeId: selectedAgentSubMode, userLanguage: chatResponseLocale @@ -1415,6 +1491,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 80c3d5a2..54026159 100644 --- a/Core/Sources/ConversationTab/ChatPanel.swift +++ b/Core/Sources/ConversationTab/ChatPanel.swift @@ -23,6 +23,7 @@ private let r: Double = 4 public struct ChatPanel: View { @Perception.Bindable var chat: StoreOf @Namespace var inputAreaNamespace + @ObservedObject private var warningManager = WarningStateManager.shared public var body: some View { WithPerceptionTracking { @@ -55,12 +56,24 @@ public struct ChatPanel: View { } } + if let warning = warningManager.currentWarning { + WarningBanner( + message: warning.message, + severity: warning.severity, + actions: warning.actions + ) { + warningManager.dismissWarning() + } + .scaledPadding(.horizontal, 24) + .scaledPadding(.vertical, 8) + } + if chat.fileEditMap.count > 0 { WorkingSetView(chat: chat) .dimWithExitEditMode(chat) - .scaledPadding(.horizontal, 16) + .scaledPadding(.horizontal, 24) } - + ChatPanelInputArea(chat: chat, r: r, editorMode: .input) .dimWithExitEditMode(chat) .scaledPadding(.horizontal, 16) @@ -135,6 +148,36 @@ private struct ListHeightPreferenceKey: PreferenceKey { } } +private struct ScrollViewConfigurator: NSViewRepresentable { + let configure: (NSScrollView) -> Void + + final class Coordinator { + var didConfigure = false + } + + func makeCoordinator() -> Coordinator { Coordinator() } + + func makeNSView(context: Context) -> NSView { + let view = NSView() + applyOnce(view: view, coordinator: context.coordinator) + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + applyOnce(view: nsView, coordinator: context.coordinator) + } + + private func applyOnce(view: NSView, coordinator: Coordinator) { + guard !coordinator.didConfigure else { return } + DispatchQueue.main.async { + guard !coordinator.didConfigure, + let scrollView = view.enclosingScrollView else { return } + coordinator.didConfigure = true + configure(scrollView) + } + } +} + struct ChatPanelMessages: View { let chat: StoreOf @State var cancellable = Set() @@ -154,17 +197,34 @@ struct ChatPanelMessages: View { WithPerceptionTracking { ScrollViewReader { proxy in GeometryReader { listGeo in - List { - Group { + ScrollView(.vertical, showsIndicators: true) { + // VStack with a flexible trailing Spacer absorbs empty space when + // content is shorter than the viewport, so content stays naturally + // top-aligned. When content grows past the viewport, the Spacer + // collapses to its minLength and the VStack overflows the + // ScrollView's content area as expected. This avoids the List's + // remembered-bottom-anchor behavior that pushed earlier content up + // whenever a child view's height changed. + VStack(alignment: .leading, spacing: 0) { + ScrollViewConfigurator { scrollView in + scrollView.scrollerStyle = .overlay + scrollView.verticalScroller?.scrollerStyle = .overlay + scrollView.autohidesScrollers = true + } + .frame(width: 0, height: 0) + + Color.clear + .frame(height: 1) + .id(topID) ChatHistory(chat: chat) .fixedSize(horizontal: false, vertical: true) ExtraSpacingInResponding(chat: chat) - Spacer(minLength: 12) + Color.clear + .frame(height: 12) .id(bottomID) - .listRowInsets(EdgeInsets()) .onAppear { isBottomHidden = false if !didScrollToBottomOnAppearOnce { @@ -182,25 +242,16 @@ struct ChatPanelMessages: View { value: offset ) }) + + Spacer(minLength: 0) } - .modify { view in - if #available(macOS 13.0, *) { - view - .listRowSeparator(.hidden) - } else { - view - } - } - } - .listStyle(.plain) - .scaledPadding(.leading, 8) - .listRowBackground(EmptyView()) - .modify { view in - if #available(macOS 13.0, *) { - view.scrollContentBackground(.hidden) - } else { - view - } + .frame( + minWidth: 0, + maxWidth: .infinity, + minHeight: listGeo.size.height, + alignment: .topLeading + ) + .scaledPadding(.horizontal, 16) } .coordinateSpace(name: scrollSpace) .preference( diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModeAndModelPickerPicker.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModeAndModelPickerPicker.swift index ec1abafb..e8302f4e 100644 --- a/Core/Sources/ConversationTab/ModeAndModelPicker/ModeAndModelPickerPicker.swift +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModeAndModelPickerPicker.swift @@ -13,13 +13,10 @@ struct ModeAndModelPicker: View { @Binding var selectedAgent: ConversationMode @State private var selectedModel: LLMModel? - @State private var isHovered = false - @State private var isPressed = false @ObservedObject private var modelManager = CopilotModelManagerObservable.shared static var lastRefreshModelsTime: Date = .init(timeIntervalSince1970: 0) @State private var chatMode = "Ask" - @State private var isAgentPickerHovered = false // Separate caches for both scopes @State private var askScopeCache: ScopeCache = ScopeCache() @@ -27,14 +24,7 @@ struct ModeAndModelPicker: View { @State var isMCPFFEnabled: Bool @State var isBYOKFFEnabled: Bool - @State var isEditorPreviewEnabled: Bool @State private var cancellables = Set() - - @StateObject private var fontScaleManager = FontScaleManager.shared - - var fontScale: Double { - fontScaleManager.currentScale - } let attributes: [NSAttributedString.Key: NSFont] = ModelMenuItemFormatter.attributes @@ -46,7 +36,6 @@ struct ModeAndModelPicker: View { self._selectedModel = State(initialValue: initialModel) self.isMCPFFEnabled = FeatureFlagNotifierImpl.shared.featureFlags.mcp self.isBYOKFFEnabled = FeatureFlagNotifierImpl.shared.featureFlags.byok - self.isEditorPreviewEnabled = FeatureFlagNotifierImpl.shared.featureFlags.editorPreviewFeatures updateAgentPicker() } @@ -54,7 +43,6 @@ struct ModeAndModelPicker: View { FeatureFlagNotifierImpl.shared.featureFlagsDidChange.sink(receiveValue: { featureFlags in isMCPFFEnabled = featureFlags.mcp isBYOKFFEnabled = featureFlags.byok - isEditorPreviewEnabled = featureFlags.editorPreviewFeatures }) .store(in: &cancellables) } @@ -78,26 +66,11 @@ struct ModeAndModelPicker: View { AppState.shared.isAgentModeEnabled() ? agentScopeCache : askScopeCache } - // Helper method to format multiplier text - func formatMultiplierText(for billing: CopilotModelBilling?) -> String { - guard let billingInfo = billing else { return "" } - - let multiplier = billingInfo.multiplier - if multiplier == 0 { - return "Included" - } else { - let numberPart = multiplier.truncatingRemainder(dividingBy: 1) == 0 - ? String(format: "%.0f", multiplier) - : String(format: "%.2f", multiplier) - return "\(numberPart)x" - } - } - // Update cache for specific scope only if models changed func updateModelCacheIfNeeded(for scope: PromptTemplateScope) { - let currentModels = scope == .agentPanel ? - modelManager.availableAgentModels + modelManager.availableAgentBYOKModels : - modelManager.availableChatModels + modelManager.availableChatBYOKModels + let clsModels = scope == .agentPanel ? modelManager.availableAgentModels : modelManager.availableChatModels + let byokModels = isBYOKFFEnabled ? (scope == .agentPanel ? modelManager.availableAgentBYOKModels : modelManager.availableChatBYOKModels) : [] + let currentModels = clsModels + byokModels let modelsHash = currentModels.hashValue if scope == .agentPanel { @@ -143,28 +116,13 @@ struct ModeAndModelPicker: View { allAvailableModels += byokModels } - // If editor preview is disabled and current model is auto, switch away from it - if !isEditorPreviewEnabled && currentModel?.isAutoModel == true { - // Try default model first - if let defaultModel = defaultModel, !defaultModel.isAutoModel { - AppState.shared.setSelectedModel(defaultModel) - selectedModel = defaultModel - return - } - // If default is also auto, use first non-auto available model - if let firstNonAuto = allAvailableModels.first(where: { !$0.isAutoModel }) { - AppState.shared.setSelectedModel(firstNonAuto) - selectedModel = firstNonAuto - return - } - } - - // Check if current model exists in available models for current scope using model comparison - let modelExists = allAvailableModels.contains { model in + // Find the fresh model from available models that matches the persisted selection. + // This ensures transient fields like degradationReason stay up to date. + let freshModel = allAvailableModels.first { model in model == currentModel } - - if !modelExists && currentModel != nil { + + if freshModel == nil && currentModel != nil { // Switch to default model if current model is not available if let fallbackModel = defaultModel { AppState.shared.setSelectedModel(fallbackModel) @@ -177,7 +135,12 @@ struct ModeAndModelPicker: View { selectedModel = nil } } else { - selectedModel = currentModel ?? defaultModel + if let fresh = freshModel, let current = currentModel, + fresh.supportsReasoningEffortLevel != current.supportsReasoningEffortLevel + || fresh.reasoningEfforts != current.reasoningEfforts { + AppState.shared.setSelectedModel(fresh) + } + selectedModel = freshModel ?? defaultModel } } @@ -258,85 +221,6 @@ struct ModeAndModelPicker: View { } } - // Model picker menu component - private var modelPickerMenu: some View { - Menu { - // Group models by premium status - let premiumModels = copilotModels.filter { $0.isPremiumModel } - let standardModels = copilotModels.filter { - $0.isStandardModel && !$0.isAutoModel - } - let autoModel = isEditorPreviewEnabled ? copilotModels.first(where: { $0.isAutoModel }) : nil - - // Always `Auto Model` on top if available - if let autoModel { - modelButton(for: autoModel) - } - - // Display standard models section if available - modelSection(title: "Standard Models", models: standardModels) - - // Display premium models section if available - modelSection(title: "Premium Models", models: premiumModels) - - if isBYOKFFEnabled { - // Display byok models section if available - modelSection(title: "Other Models", models: byokModels) - - Button("Manage Models...") { - try? launchHostAppBYOKSettings() - } - } - - if standardModels.isEmpty { - Link("Add Premium Models", destination: URL(string: "https://aka.ms/github-copilot-upgrade-plan")!) - } - } label: { - Text(selectedModel?.displayName ?? selectedModel?.modelName ?? "") - // scaledFont not work here. workaround by direclty use the fontScale - .font(.system(size: 13 * fontScale)) - } - .menuStyle(BorderlessButtonMenuStyle()) - .frame(maxWidth: labelWidth()) - .scaledPadding(4) - .background( - RoundedRectangle(cornerRadius: 5) - .fill(isHovered ? Color.gray.opacity(0.1) : Color.clear) - ) - .onHover { hovering in - isHovered = hovering - } - } - - // Helper function to create a section of model options - @ViewBuilder - private func modelSection(title: String, models: [LLMModel]) -> some View { - if !models.isEmpty { - Section(title) { - ForEach(models, id: \.self) { model in - modelButton(for: model) - } - } - } - } - - // Helper function to create a model selection button - private func modelButton(for model: LLMModel) -> some View { - Button { - AppState.shared.setSelectedModel(model) - } label: { - Text(createModelMenuItemAttributedString( - modelName: model.displayName ?? model.modelName, - isSelected: selectedModel == model, - cachedMultiplierText: currentCache.modelMultiplierCache[model.id.appending(model.providerName ?? "")] ?? "" - )) - } - .help( - model.isAutoModel - ? "Auto selects the best model for your request based on capacity and performance." - : model.displayName ?? model.modelName) - } - private var mcpButton: some View { Group { if isMCPFFEnabled { @@ -393,7 +277,13 @@ struct ModeAndModelPicker: View { // Model Picker Group { if !copilotModels.isEmpty && selectedModel != nil { - modelPickerMenu + ChatModelPicker( + selectedModel: selectedModel, + copilotModels: copilotModels, + byokModels: byokModels, + isBYOKFFEnabled: isBYOKFFEnabled, + currentCache: currentCache + ) } else { EmptyView() } @@ -433,9 +323,6 @@ struct ModeAndModelPicker: View { .onChange(of: isBYOKFFEnabled) { _ in updateCurrentModel() } - .onChange(of: isEditorPreviewEnabled) { _ in - updateCurrentModel() - } .onReceive(NotificationCenter.default.publisher(for: .gitHubCopilotSelectedModelDidChange)) { _ in updateCurrentModel() } @@ -445,15 +332,6 @@ struct ModeAndModelPicker: View { } } - func labelWidth() -> CGFloat { - guard let selectedModel = selectedModel else { return 100 } - let displayName = selectedModel.displayName ?? selectedModel.modelName - let width = displayName.size( - withAttributes: attributes - ).width - return CGFloat(width * fontScale + 20) - } - @MainActor func refreshModels() async { let now = Date() @@ -468,18 +346,6 @@ struct ModeAndModelPicker: View { } } - private func createModelMenuItemAttributedString( - modelName: String, - isSelected: Bool, - cachedMultiplierText: String - ) -> AttributedString { - return ModelMenuItemFormatter.createModelMenuItemAttributedString( - modelName: modelName, - isSelected: isSelected, - multiplierText: cachedMultiplierText, - targetWidth: currentCache.cachedMaxWidth - ) - } } struct ModelPicker_Previews: PreviewProvider { diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeButtonMenuItem.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeButtonMenuItem.swift index 322bac6d..5ed180f8 100644 --- a/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeButtonMenuItem.swift +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeButtonMenuItem.swift @@ -466,27 +466,11 @@ class AgentModeButtonMenuItem: NSView { super.draw(dirtyRect) if isHovered { - NSGraphicsContext.saveGraphicsState() - - let hoverColor = NSColor(.accentColor) - hoverColor.setFill() - - let cornerRadius: CGFloat - if #available(macOS 26.0, *) { - cornerRadius = 8.0 * fontScale - } else { - cornerRadius = 4.0 * fontScale - } - - // Use frame dimensions instead of bounds to avoid layout recursion - let viewWidth = frame.width - let viewHeight = frame.height - let hoverWidth = viewWidth - (scaledConstants.hoverEdgeInset * 2) - let insetRect = NSRect(x: scaledConstants.hoverEdgeInset, y: 0, width: hoverWidth, height: viewHeight) - let path = NSBezierPath(roundedRect: insetRect, xRadius: cornerRadius, yRadius: cornerRadius) - path.fill() - - NSGraphicsContext.restoreGraphicsState() + ModelMenuItemFormatter.drawMenuItemHighlight( + in: frame, + fontScale: fontScale, + hoverEdgeInset: scaledConstants.hoverEdgeInset + ) } } diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/ChatModePicker.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/ChatModePicker.swift index 97268560..641a4489 100644 --- a/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/ChatModePicker.swift +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/ChatModePicker.swift @@ -23,7 +23,6 @@ public struct ChatModePicker: View { let projectRootURL: URL? @Environment(\.colorScheme) var colorScheme @State var isAgentModeFFEnabled: Bool - @State var isEditorPreviewFFEnabled: Bool @State var isCustomAgentPolicyEnabled: Bool @State private var cancellables = Set() @State private var builtInAgents: [ConversationMode] = [] @@ -44,7 +43,6 @@ public struct ChatModePicker: View { self.projectRootURL = projectRootURL self.onScopeChange = onScopeChange isAgentModeFFEnabled = FeatureFlagNotifierImpl.shared.featureFlags.agentMode - isEditorPreviewFFEnabled = FeatureFlagNotifierImpl.shared.featureFlags.editorPreviewFeatures isCustomAgentPolicyEnabled = CopilotPolicyNotifierImpl.shared.copilotPolicy.customAgentEnabled } @@ -78,7 +76,6 @@ public struct ChatModePicker: View { private func subscribeToFeatureFlagsDidChangeEvent() { FeatureFlagNotifierImpl.shared.featureFlagsDidChange.sink(receiveValue: { featureFlags in isAgentModeFFEnabled = featureFlags.agentMode - isEditorPreviewFFEnabled = featureFlags.editorPreviewFeatures }) .store(in: &cancellables) } @@ -188,7 +185,7 @@ public struct ChatModePicker: View { customAgents: customAgents, selectedAgent: selectedAgent, selectedIconName: displayIconName, - isCustomAgentEnabled: isEditorPreviewFFEnabled && isCustomAgentPolicyEnabled, + isCustomAgentEnabled: isCustomAgentPolicyEnabled, onSelectAgent: { setAgentMode($0) }, onEditAgent: { openAgentFileInXcode($0) }, onDeleteAgent: { deleteCustomAgent($0) }, @@ -219,13 +216,6 @@ public struct ChatModePicker: View { setAskMode() } } - .onChange(of: isEditorPreviewFFEnabled) { newValue in - // If editor preview is disabled and current agent is not the default agent, reset to default - if !newValue && chatMode == ChatMode.Agent.rawValue && !selectedAgent.isDefaultAgent { - let defaultAgent = builtInAgents.first(where: { $0.isDefaultAgent }) ?? .defaultAgent - setAgentMode(defaultAgent) - } - } .onChange(of: isCustomAgentPolicyEnabled) { newValue in // If custom agent policy is disabled and current agent is not the default agent, reset to default if !newValue && chatMode == ChatMode.Agent.rawValue && !selectedAgent.isDefaultAgent { @@ -277,7 +267,7 @@ public struct ChatModePicker: View { // Try to find the agent if let agent = findAgent(byId: subMode) { // If it's not the default agent and custom agents are disabled, reset to default - if !agent.isDefaultAgent && (!isEditorPreviewFFEnabled || !isCustomAgentPolicyEnabled) { + if !agent.isDefaultAgent && !isCustomAgentPolicyEnabled { selectedAgent = builtInAgents.first(where: { $0.isDefaultAgent }) ?? .defaultAgent AppState.shared.setSelectedAgentSubMode("Agent") return diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModelManagerUtils.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelManagerUtils.swift index 53eeeb6e..a4542c90 100644 --- a/Core/Sources/ConversationTab/ModeAndModelPicker/ModelManagerUtils.swift +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelManagerUtils.swift @@ -7,9 +7,11 @@ import ConversationServiceProvider public let SELECTED_LLM_KEY = "selectedLLM" public let SELECTED_CHATMODE_KEY = "selectedChatMode" public let SELECTED_AGENT_SUBMODE_KEY = "selectedAgentSubMode" +public let SELECTED_REASONING_EFFORT_KEY = "selectedReasoningEffort" public extension Notification.Name { static let gitHubCopilotSelectedModelDidChange = Notification.Name("com.github.CopilotForXcode.SelectedModelDidChange") + static let gitHubCopilotSelectedReasoningEffortDidChange = Notification.Name("com.github.CopilotForXcode.SelectedReasoningEffortDidChange") } public extension AppState { @@ -34,7 +36,13 @@ public extension AppState { let displayName = savedModel["displayName"]?.stringValue let providerName = savedModel["providerName"]?.stringValue let supportVision = savedModel["supportVision"]?.boolValue ?? false - + let degradationReason = savedModel["degradationReason"]?.stringValue + let supportsReasoningEffortLevel = savedModel["supportsReasoningEffortLevel"]?.boolValue ?? false + var reasoningEfforts: [String]? = nil + if case .array(let arr)? = savedModel["reasoningEfforts"] { + reasoningEfforts = arr.compactMap { $0.stringValue } + } + // Try to reconstruct billing info if available var billing: CopilotModelBilling? if let isPremium = savedModel["billing"]?["isPremium"]?.boolValue, @@ -44,7 +52,7 @@ public extension AppState { multiplier: Float(multiplier) ) } - + return LLMModel( displayName: displayName, modelName: modelName, @@ -52,7 +60,10 @@ public extension AppState { id: id, billing: billing, providerName: providerName, - supportVision: supportVision + supportVision: supportVision, + degradationReason: degradationReason, + reasoningEfforts: reasoningEfforts, + supportsReasoningEffortLevel: supportsReasoningEffortLevel ) } @@ -63,6 +74,41 @@ public extension AppState { } } + func getSelectedReasoningEffort(for model: LLMModel) -> String? { + guard let saved = get(key: SELECTED_REASONING_EFFORT_KEY) else { return nil } + return saved[model.reasoningEffortStorageKey]?.stringValue + } + + func setSelectedReasoningEffort(_ effort: String, for model: LLMModel) { + var efforts: [String: String] = [:] + if let existing = get(key: SELECTED_REASONING_EFFORT_KEY), + case .hash(let dict) = existing { + for (k, v) in dict { + if let s = v.stringValue { efforts[k] = s } + } + } + efforts[model.reasoningEffortStorageKey] = effort + update(key: SELECTED_REASONING_EFFORT_KEY, value: efforts) + DispatchQueue.main.async { + NotificationCenter.default.post(name: .gitHubCopilotSelectedReasoningEffortDidChange, object: nil) + } + } + + /// Returns the effective reasoning effort for a given model: + /// - `nil` if the model does not support reasoning effort + /// - `nil` for the auto model — lets the server pick the effort for whichever model it routes to + /// - the user-persisted value if set + /// - otherwise the model-family default: "medium" for all models + func effectiveReasoningEffort(for model: LLMModel) -> String? { + guard model.supportsReasoningEffortLevel else { return nil } + guard !model.isAutoModel else { return nil } + let candidate = getSelectedReasoningEffort(for: model) ?? model.defaultReasoningEffort + if let efforts = model.reasoningEfforts, !efforts.isEmpty { + return efforts.contains(candidate) ? candidate : efforts.first + } + return candidate + } + func modelScope() -> PromptTemplateScope { return isAgentModeEnabled() ? .agentPanel : .chatPanel } @@ -148,15 +194,7 @@ public class CopilotModelManagerObservable: ObservableObject { scope: AppState.shared .isAgentModeEnabled() ? .agentPanel : .chatPanel ) { - AppState.shared.setSelectedModel( - .init( - modelName: fallbackModel.modelName, - modelFamily: fallbackModel.modelFamily, - id: fallbackModel.id, - billing: fallbackModel.billing, - supportVision: fallbackModel.capabilities.supports.vision - ) - ) + AppState.shared.setSelectedModel(fallbackModel.toLLMModel()) } } .store(in: &cancellables) @@ -170,52 +208,28 @@ public extension CopilotModelManager { return LLMs.filter( { $0.scopes.contains(scope) } ).map { - return LLMModel( - modelName: $0.modelName, - modelFamily: $0.isChatFallback ? $0.id : $0.modelFamily, - id: $0.id, - billing: $0.billing, - supportVision: $0.capabilities.supports.vision - ) + $0.toLLMModel(familyOverride: $0.isChatFallback ? $0.id : nil) } } static func getDefaultChatModel(scope: PromptTemplateScope = .chatPanel) -> LLMModel? { let LLMs = CopilotModelManager.getAvailableLLMs() let LLMsInScope = LLMs.filter({ $0.scopes.contains(scope) }) - let defaultModel = LLMsInScope.first(where: { $0.isChatDefault && !$0.isAutoModel }) + let defaultModel = LLMsInScope.first(where: { $0.isChatDefault && $0.isAutoModel }) + ?? LLMsInScope.first(where: { $0.isChatDefault }) // If a default model is found, return it if let defaultModel = defaultModel { - return LLMModel( - modelName: defaultModel.modelName, - modelFamily: defaultModel.modelFamily, - id: defaultModel.id, - billing: defaultModel.billing, - supportVision: defaultModel.capabilities.supports.vision - ) + return defaultModel.toLLMModel() } // Fallback to gpt-4.1 if available - let gpt4_1 = LLMsInScope.first(where: { $0.modelFamily == "gpt-4.1" }) - if let gpt4_1 = gpt4_1 { - return LLMModel( - modelName: gpt4_1.modelName, - modelFamily: gpt4_1.modelFamily, - id: gpt4_1.id, - billing: gpt4_1.billing, - supportVision: gpt4_1.capabilities.supports.vision - ) + if let gpt4_1 = LLMsInScope.first(where: { $0.modelFamily == "gpt-4.1" }) { + return gpt4_1.toLLMModel() } // If no default model is found, fallback to the first available model - if let firstModel = LLMsInScope.first(where: { !$0.isAutoModel }) { - return LLMModel( - modelName: firstModel.modelName, - modelFamily: firstModel.modelFamily, - id: firstModel.id, - billing: firstModel.billing, - supportVision: firstModel.capabilities.supports.vision - ) + if let firstModel = LLMsInScope.first { + return firstModel.toLLMModel() } return nil @@ -239,7 +253,9 @@ public extension BYOKModelManager { id: $0.modelId, billing: nil, providerName: $0.providerName.rawValue, - supportVision: $0.modelCapabilities?.vision ?? false + supportVision: $0.modelCapabilities?.vision ?? false, + maxInputTokens: $0.modelCapabilities?.maxInputTokens, + maxOutputTokens: $0.modelCapabilities?.maxOutputTokens ) } } @@ -250,26 +266,76 @@ public struct LLMModel: Codable, Hashable, Equatable { public let modelName: String public let modelFamily: String public let id: String + public let vendor: String? public let billing: CopilotModelBilling? public let providerName: String? public let supportVision: Bool - + public let degradationReason: String? + public let maxInputTokens: Int? + public let maxOutputTokens: Int? + public let maxContextWindowTokens: Int? + public let modelPickerCategory: String? + public let modelPickerPriceCategory: String? + public let reasoningEfforts: [String]? + public let supportsReasoningEffortLevel: Bool + public init( displayName: String? = nil, modelName: String, modelFamily: String, id: String, - billing: CopilotModelBilling?, + vendor: String? = nil, + billing: CopilotModelBilling? = nil, providerName: String? = nil, - supportVision: Bool + supportVision: Bool, + degradationReason: String? = nil, + maxInputTokens: Int? = nil, + maxOutputTokens: Int? = nil, + maxContextWindowTokens: Int? = nil, + modelPickerCategory: String? = nil, + modelPickerPriceCategory: String? = nil, + reasoningEfforts: [String]? = nil, + supportsReasoningEffortLevel: Bool = false ) { self.displayName = displayName self.modelName = modelName self.modelFamily = modelFamily self.id = id + self.vendor = vendor self.billing = billing self.providerName = providerName self.supportVision = supportVision + self.degradationReason = degradationReason + self.maxInputTokens = maxInputTokens + self.maxOutputTokens = maxOutputTokens + self.maxContextWindowTokens = maxContextWindowTokens + self.modelPickerCategory = modelPickerCategory + self.modelPickerPriceCategory = modelPickerPriceCategory + self.reasoningEfforts = reasoningEfforts + self.supportsReasoningEffortLevel = supportsReasoningEffortLevel + } + + // Only compare model identity fields; exclude transient/display-only data + // (billing, degradationReason, vendor, token limits) so that a persisted + // model still matches a freshly-fetched one. + 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.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(providerName) + hasher.combine(supportVision) + hasher.combine(maxContextWindowTokens) + hasher.combine(modelPickerPriceCategory) } } @@ -280,8 +346,35 @@ public extension LLMModel { var isStandardModel: Bool { !isPremiumModel || billing == nil } /// Apply to `Copilot Models` var isAutoModel: Bool { isStandardModel && modelName == "Auto" } + + var reasoningEffortStorageKey: String { + "\(id)_\(providerName ?? "")" + } + + var defaultReasoningEffort: String { + "medium" + } } extension CopilotModel { var isAutoModel: Bool { modelName == "Auto" } + + func toLLMModel(familyOverride: String? = nil) -> LLMModel { + LLMModel( + modelName: modelName, + modelFamily: familyOverride ?? modelFamily, + id: id, + vendor: vendor, + billing: billing, + supportVision: capabilities.supports.vision, + degradationReason: degradationReason, + maxInputTokens: capabilities.limits?.maxInputTokens, + maxOutputTokens: capabilities.limits?.maxOutputTokens, + maxContextWindowTokens: capabilities.limits?.maxContextWindowTokens, + modelPickerCategory: modelPickerCategory, + modelPickerPriceCategory: modelPickerPriceCategory, + reasoningEfforts: capabilities.supports.reasoningEfforts, + supportsReasoningEffortLevel: capabilities.supports.supportsReasoningEffortLevel ?? false + ) + } } diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModelMenuItemFormatter.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelMenuItemFormatter.swift index 7b32efc8..9b6abf0e 100644 --- a/Core/Sources/ConversationTab/ModeAndModelPicker/ModelMenuItemFormatter.swift +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelMenuItemFormatter.swift @@ -9,7 +9,7 @@ public struct ScopeCache { // MARK: - Model Menu Item Formatting public struct ModelMenuItemFormatter { - public static let minimumPadding: Int = 48 + public static let minimumPadding: Int = 24 public static let attributes: [NSAttributedString.Key: NSFont] = [.font: NSFont.systemFont(ofSize: NSFont.systemFontSize)] @@ -26,9 +26,18 @@ public struct ModelMenuItemFormatter { modelName: String, isSelected: Bool, multiplierText: String, - targetWidth: CGFloat? = nil + targetWidth: CGFloat? = nil, + isDegraded: Bool = false ) -> AttributedString { - let displayName = isSelected ? "✓ \(modelName)" : " \(modelName)" + let prefix: String + if isDegraded { + prefix = "⚠ " + } else if isSelected { + prefix = "✓ " + } else { + prefix = " " + } + let displayName = "\(prefix)\(modelName)" var fullString = displayName var attributedString = AttributedString(fullString) @@ -64,24 +73,98 @@ public struct ModelMenuItemFormatter { 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 { + /// Gets the trailing text for a model menu item. + /// - BYOK models: provider name + /// - Copilot models with token-based billing: "] · " + /// - Copilot models without token-based billing: "x" + /// - Auto model: "Variable" + public static func getMultiplierText(for model: LLMModel, reasoningEffort: String? = nil) -> String { + if let providerName = model.providerName, !providerName.isEmpty { + return providerName + } 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" + } + if model.billing?.tokenBasedBillingEnabled == true { + var parts: [String] = [] + if let tokens = model.maxContextWindowTokens { + parts.append(formatContextWindow(tokens)) } - } else if let providerName = model.providerName, !providerName.isEmpty { - return providerName + if let effort = reasoningEffort, !effort.isEmpty, effort.lowercased() != "none" { + parts.append(effort.capitalized) + } + if let category = model.modelPickerPriceCategory, !category.isEmpty { + parts.append(priceCategorySymbol(category)) + } + return parts.joined(separator: " · ") + } + if let multiplier = model.billing?.multiplier { + return formatMultiplier(multiplier) + } + return "" + } + + public static func priceCategorySymbol(_ category: String) -> String { + switch category.lowercased() { + case "low": return "$" + case "medium": return "$$" + case "high": return "$$$" + default: return "$$$$" + } + } + + public static func formatContextWindow(_ count: Int) -> String { + if count >= 1_000_000 { + let m = Double(count) / 1_000_000.0 + return m.truncatingRemainder(dividingBy: 1) == 0 + ? String(format: "%.0fM", m) + : String(format: "%.1fM", m) + } + if count >= 1_000 { + let k = Double(count) / 1_000.0 + return k.truncatingRemainder(dividingBy: 1) == 0 + ? String(format: "%.0fK", k) + : String(format: "%.1fK", k) + } + return "\(count)" + } + + private static func formatMultiplier(_ multiplier: Float) -> String { + if multiplier == 0 { return "Included" } + return multiplier.truncatingRemainder(dividingBy: 1) == 0 + ? String(format: "%.0fx", multiplier) + : String(format: "%.2fx", multiplier) + } + + /// 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 { - return "" + 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..c662269d --- /dev/null +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ChatModelPicker.swift @@ -0,0 +1,60 @@ +import Persist +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 + @State private var currentEffort: String? + + private var fontScale: Double { + fontScaleManager.currentScale + } + + var body: some View { + ModelPickerButton( + selectedModel: selectedModel, + copilotModels: copilotModels, + byokModels: byokModels, + isBYOKFFEnabled: isBYOKFFEnabled, + currentCache: currentCache, + fontScale: fontScale, + currentEffort: currentEffort + ) + .fixedSize(horizontal: false, vertical: true) + .onAppear { + currentEffort = computeEffort(for: selectedModel) + } + .onChange(of: selectedModel) { model in + currentEffort = computeEffort(for: model) + } + .onReceive( + NotificationCenter.default.publisher( + for: .gitHubCopilotModelsDidChange + ) + ) { _ in + currentEffort = computeEffort(for: selectedModel) + } + .onReceive( + NotificationCenter.default.publisher( + for: .gitHubCopilotSelectedReasoningEffortDidChange + ) + ) { _ in + currentEffort = computeEffort(for: selectedModel) + } + } + + private func computeEffort(for model: LLMModel?) -> String? { + guard let model, + model.supportsReasoningEffortLevel, + !model.isAutoModel else { return nil } + let effort = AppState.shared.effectiveReasoningEffort(for: model) + guard let e = effort, e.lowercased() != "none" else { return nil } + return e + } +} diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ModelPickerButton.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ModelPickerButton.swift new file mode 100644 index 00000000..cc20d1a0 --- /dev/null +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ModelPickerButton.swift @@ -0,0 +1,289 @@ +import AppKit +import Persist +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 + let currentEffort: String? + + 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 font = NSFont.systemFont(ofSize: 13 * fontScale) + let baseName = modelDisplayName + let effort = currentEffort + + let attrStr = NSMutableAttributedString( + string: baseName, + attributes: [.font: font, .foregroundColor: NSColor.labelColor] + ) + if let effort { + attrStr.append(NSAttributedString( + string: " · \(effort.capitalized)", + attributes: [.font: font, .foregroundColor: NSColor.secondaryLabelColor] + )) + } + titleLabel.attributedStringValue = attrStr + + 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.15).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 label = selectedModelLabel + 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 modelDisplayName: String { + let name = selectedModel?.displayName ?? selectedModel?.modelName ?? "" + if selectedModel?.degradationReason != nil { return "\u{26A0} \(name)" } + return name + } + + private var selectedModelLabel: String { + if let effort = currentEffort { + return "\(modelDisplayName) · \(effort.capitalized)" + } + return modelDisplayName + } + + 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.15).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..e17fd29f --- /dev/null +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ModelPickerDetailPanel.swift @@ -0,0 +1,657 @@ +import AppKit +import Persist + +// MARK: - Floating Detail Panel (shown on menu item hover) + +private class MouseTrackingVisualEffectView: NSVisualEffectView { + var onMouseEntered: (() -> Void)? + var onMouseExited: (() -> Void)? + private var mouseTrackingArea: NSTrackingArea? + + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let existing = mouseTrackingArea { + removeTrackingArea(existing) + } + let area = NSTrackingArea( + rect: .zero, + options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect], + owner: self, + userInfo: nil + ) + addTrackingArea(area) + mouseTrackingArea = area + } + + override func mouseEntered(with event: NSEvent) { onMouseEntered?() } + override func mouseExited(with event: NSEvent) { onMouseExited?() } +} + +class ModelPickerDetailPanel: NSPanel { + static let shared = ModelPickerDetailPanel() + + private let containerStack = NSStackView() + private var hideTimer: Timer? + + private var containerConstraints: [NSLayoutConstraint] = [] + private var currentFontScale: CGFloat = 1.0 + private var currentModel: LLMModel? + private var onModelSelect: (() -> Void)? + + // Clickable rows: (view in panel-local hierarchy, action, (label, restore color) pairs) + private var clickableRows: [(view: NSView, action: () -> Void, labels: [(NSTextField, NSColor)])] = [] + private var hoveredRow: NSView? + + // Event interception during NSMenu tracking + private var mousePollingTimer: Timer? + private var localEventMonitor: Any? + private var wasMouseDown: Bool = false + + private init() { + super.init( + contentRect: NSRect(x: 0, y: 0, width: 200, height: 80), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: true + ) + self.isFloatingPanel = true + self.level = .popUpMenu + 1 + self.isOpaque = true + self.backgroundColor = .clear + self.hidesOnDeactivate = false + self.hasShadow = true + self.isMovable = false + self.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + setupContent() + } + + private static func roundedCornerMask(radius: CGFloat) -> NSImage { + let diameter = radius * 2 + let image = NSImage(size: NSSize(width: diameter, height: diameter), flipped: false) { rect in + let path = NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius) + NSColor.black.setFill() + path.fill() + return true + } + image.capInsets = NSEdgeInsets(top: radius, left: radius, bottom: radius, right: radius) + image.resizingMode = .stretch + return image + } + + private func setupContent() { + let visual = MouseTrackingVisualEffectView() + visual.onMouseEntered = { [weak self] in self?.cancelHide() } + visual.onMouseExited = { [weak self] in self?.scheduleHide() } + visual.material = .popover + visual.state = .active + visual.wantsLayer = true + visual.maskImage = Self.roundedCornerMask(radius: 8) + visual.translatesAutoresizingMaskIntoConstraints = false + + containerStack.orientation = .vertical + containerStack.alignment = .leading + containerStack.spacing = 6 + containerStack.translatesAutoresizingMaskIntoConstraints = false + + visual.addSubview(containerStack) + self.contentView = visual + + applyScaledConstraints(to: visual, fontScale: 1.0) + } + + private func applyScaledConstraints(to visual: NSView, fontScale: CGFloat) { + NSLayoutConstraint.deactivate(containerConstraints) + + let padding: CGFloat = 8 * fontScale + let horizontalPadding: CGFloat = 10 * fontScale + + containerConstraints = [ + containerStack.topAnchor.constraint(equalTo: visual.topAnchor, constant: padding), + containerStack.leadingAnchor.constraint(equalTo: visual.leadingAnchor, constant: horizontalPadding), + containerStack.trailingAnchor.constraint(equalTo: visual.trailingAnchor, constant: -horizontalPadding), + containerStack.bottomAnchor.constraint(equalTo: visual.bottomAnchor, constant: -padding), + ] + + NSLayoutConstraint.activate(containerConstraints) + + if let visual = visual as? NSVisualEffectView { + visual.maskImage = Self.roundedCornerMask(radius: 8 * fontScale) + } + currentFontScale = fontScale + } + + // MARK: - Interactivity (works during NSMenu event tracking) + + private func startInteractivity() { + stopInteractivity() + wasMouseDown = (NSEvent.pressedMouseButtons & 1) != 0 + + // Poll mouse location every 50ms in .common mode so it fires during NSMenu tracking. + // We also use this loop to detect clicks on the panel, because + // `addLocalMonitorForEvents` does not reliably fire while NSMenu owns + // the event loop (the menu eats clicks outside its bounds before our + // monitor runs), so polling is the only thing that works here. + let timer = Timer(timeInterval: 0.05, repeats: true) { [weak self] _ in + guard let self = self, self.isVisible else { return } + let mouse = NSEvent.mouseLocation + let isMouseDown = (NSEvent.pressedMouseButtons & 1) != 0 + let isOverPanel = self.frame.contains(mouse) + + if isOverPanel { + self.cancelHide() + self.updateHoveredRow(at: mouse) + + // Detect mouse-down transition while over a row → trigger action. + if isMouseDown, !self.wasMouseDown { + self.handleClickInPanel(at: mouse) + } + } else { + self.clearHoveredRow() + } + + self.wasMouseDown = isMouseDown + } + mousePollingTimer = timer + RunLoop.current.add(timer, forMode: .common) + + // Local monitor as a secondary path (fires when no menu is tracking). + localEventMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) { [weak self] event in + guard let self = self, self.isVisible else { return event } + let screenLocation = NSEvent.mouseLocation + if self.frame.contains(screenLocation) { + self.handleClickInPanel(at: screenLocation) + return nil + } + return event + } + } + + private func stopInteractivity() { + mousePollingTimer?.invalidate() + mousePollingTimer = nil + if let monitor = localEventMonitor { + NSEvent.removeMonitor(monitor) + localEventMonitor = nil + } + clearHoveredRow() + } + + private func updateHoveredRow(at mouseLocation: NSPoint) { + let target = rowAtScreenLocation(mouseLocation)?.view + + if target !== hoveredRow { + restoreColors(for: hoveredRow) + hoveredRow?.layer?.backgroundColor = NSColor.clear.cgColor + if let target = target { + target.layer?.backgroundColor = NSColor.controlAccentColor.cgColor + applyHoverColors(for: target) + } + hoveredRow = target + } + } + + private func clearHoveredRow() { + restoreColors(for: hoveredRow) + hoveredRow?.layer?.backgroundColor = NSColor.clear.cgColor + hoveredRow = nil + } + + private func applyHoverColors(for row: NSView) { + guard let entry = clickableRows.first(where: { $0.view === row }) else { return } + for (label, _) in entry.labels { + label.textColor = .white + } + } + + private func restoreColors(for row: NSView?) { + guard let row = row, + let entry = clickableRows.first(where: { $0.view === row }) else { return } + for (label, color) in entry.labels { + label.textColor = color + } + } + + private func handleClickInPanel(at screenLocation: NSPoint) { + rowAtScreenLocation(screenLocation)?.action() + } + + private func rowAtScreenLocation(_ screenLocation: NSPoint) -> (view: NSView, action: () -> Void)? { + let windowPoint = convertPoint(fromScreen: screenLocation) + guard let contentView = contentView else { return nil } + let contentPoint = contentView.convert(windowPoint, from: nil) + return clickableRows.first { + $0.view.convert($0.view.bounds, to: contentView).contains(contentPoint) + }.map { (view: $0.view, action: $0.action) } + } + + // MARK: - Helper: Create labels + + private func makeTitleLabel(_ text: String, scale: CGFloat) -> NSTextField { + let label = NSTextField(labelWithString: text) + label.font = NSFont.systemFont(ofSize: 13 * scale, weight: .bold) + label.textColor = .labelColor + label.lineBreakMode = .byTruncatingTail + label.setContentCompressionResistancePriority(.required, for: .horizontal) + return label + } + + private func makeBodyLabel(_ text: String, scale: CGFloat, color: NSColor = .secondaryLabelColor) -> NSTextField { + let label = NSTextField(wrappingLabelWithString: text) + label.font = NSFont.systemFont(ofSize: 12 * scale) + label.textColor = color + label.isEditable = false + label.isBordered = false + label.backgroundColor = .clear + label.drawsBackground = false + return label + } + + private func makeSeparator() -> NSBox { + let sep = NSBox() + sep.boxType = .separator + return sep + } + + private func makeCategoryBadge(_ category: String, scale: CGFloat) -> NSView { + let lowered = category.lowercased() + let color: NSColor + switch lowered { + case "powerful": color = .systemBlue + case "lightweight": color = .systemGreen + default: color = .systemGray + } + + let label = NSTextField(labelWithString: category.capitalized) + let hPad: CGFloat = 6 * scale + let vPad: CGFloat = 2 * scale + label.font = NSFont.systemFont(ofSize: 10 * scale, weight: .medium) + label.textColor = color + label.translatesAutoresizingMaskIntoConstraints = false + + let container = NSView() + container.wantsLayer = true + container.layer?.borderColor = color.cgColor + container.layer?.borderWidth = 1.0 + container.layer?.cornerRadius = (label.intrinsicContentSize.height + vPad * 2) / 2 + container.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(label) + + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: hPad), + label.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -hPad), + label.topAnchor.constraint(equalTo: container.topAnchor, constant: vPad), + label.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -vPad), + ]) + + return container + } + + private func makeKeyValueRow(_ key: String, _ value: String, scale: CGFloat) -> NSStackView { + let keyLabel = NSTextField(labelWithString: key) + keyLabel.font = NSFont.systemFont(ofSize: 12 * scale) + keyLabel.textColor = .secondaryLabelColor + keyLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + keyLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + + let valueLabel = NSTextField(labelWithString: value) + valueLabel.font = NSFont.systemFont(ofSize: 12 * scale) + valueLabel.textColor = .labelColor + valueLabel.alignment = .right + valueLabel.setContentHuggingPriority(.required, for: .horizontal) + valueLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + + let row = NSStackView(views: [keyLabel, valueLabel]) + row.orientation = .horizontal + row.distribution = .fill + row.spacing = 8 * scale + return row + } + + // MARK: - Thinking Effort Helpers + + private func effortDescription(for effort: String) -> String { + switch effort.lowercased() { + case "none": return "No reasoning applied" + case "low": return "Faster responses with less reasoning" + case "medium": return "Balanced reasoning and speed" + case "high": return "Maximum reasoning depth" + case "xhigh": return "Maximum reasoning depth but slower" + default: return "" + } + } + + private func makeThinkingEffortRow( + effort: String, + isSelected: Bool, + isDefault: Bool, + scale: CGFloat, + onSelect: @escaping () -> Void + ) -> NSView { + let checkmark = NSTextField(labelWithString: "✓") + checkmark.font = NSFont.systemFont(ofSize: 12 * scale, weight: .medium) + checkmark.textColor = .labelColor + checkmark.alphaValue = isSelected ? 1.0 : 0.0 + checkmark.setContentHuggingPriority(.required, for: .horizontal) + checkmark.setContentCompressionResistancePriority(.required, for: .horizontal) + + var effortName = effort.capitalized + if isDefault { effortName += " (default)" } + let effortLabel = NSTextField(labelWithString: effortName) + effortLabel.font = NSFont.systemFont(ofSize: 12 * scale) + effortLabel.textColor = .labelColor + effortLabel.setContentHuggingPriority(.required, for: .horizontal) + effortLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + + let description = effortDescription(for: effort) + let descLabel = NSTextField(labelWithString: description) + descLabel.font = NSFont.systemFont(ofSize: 12 * scale) + descLabel.textColor = .secondaryLabelColor + descLabel.alignment = .right + descLabel.lineBreakMode = .byTruncatingTail + descLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + descLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + let innerStack = NSStackView(views: [checkmark, effortLabel, descLabel]) + innerStack.orientation = .horizontal + innerStack.spacing = 4 * scale + innerStack.distribution = .fill + innerStack.translatesAutoresizingMaskIntoConstraints = false + + // Outer container provides taller hover hit area without changing text spacing + let container = NSView() + container.wantsLayer = true + container.layer?.cornerRadius = 4 * scale + container.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(innerStack) + + let vPad: CGFloat = 3 * scale + let hPad: CGFloat = 4 * scale + NSLayoutConstraint.activate([ + innerStack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: hPad), + innerStack.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -hPad), + innerStack.topAnchor.constraint(equalTo: container.topAnchor, constant: vPad), + innerStack.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -vPad), + ]) + + clickableRows.append((container, onSelect, [ + (checkmark, .labelColor), + (effortLabel, .labelColor), + (descLabel, .secondaryLabelColor), + ])) + + return container + } + + // MARK: - Token formatting + + private func formatPrice(_ price: Float, tokenUnit: Int?) -> String { + let unit = tokenUnit ?? 1_000_000 + let scaled = Double(price) * Double(unit) / 1_000_000.0 + if scaled == 0 { return "$ 0" } + return scaled.truncatingRemainder(dividingBy: 1) == 0 + ? String(format: "$ %.0f", scaled) + : String(format: "$ %.2f", scaled) + } + + // MARK: - Show + + func show( + for model: LLMModel, + nearRect: NSRect, + preferRight: Bool = true, + fontScale: CGFloat = 1.0, + onModelSelect: (() -> Void)? = nil + ) { + hideTimer?.invalidate() + hideTimer = nil + + currentModel = model + self.onModelSelect = onModelSelect + + if let visual = self.contentView { + applyScaledConstraints(to: visual, fontScale: fontScale) + } + + // Clear previous content + containerStack.arrangedSubviews.forEach { $0.removeFromSuperview() } + clickableRows.removeAll() + hoveredRow = nil + containerStack.spacing = 6 * fontScale + + let scale = fontScale + + // --- Title: Vendor + Display Name --- + let displayName = model.displayName ?? model.modelName + let vendorPrefix = model.vendor.map { "\($0) " } ?? "" + let titleLabel = makeTitleLabel("\(vendorPrefix)\(displayName)", scale: scale) + containerStack.addArrangedSubview(titleLabel) + titleLabel.translatesAutoresizingMaskIntoConstraints = false + + // --- Category badge --- + if let category = model.modelPickerCategory, !category.isEmpty { + let badge = makeCategoryBadge(category, scale: scale) + containerStack.addArrangedSubview(badge) + } + + // --- Degradation warning --- + if let reason = model.degradationReason { + let warningLabel = makeBodyLabel("\u{26A0} \(reason)", scale: scale, color: .labelColor) + containerStack.addArrangedSubview(warningLabel) + } + + // --- Auto model description --- + if model.isAutoModel { + let desc = makeBodyLabel( + "Automatically selects the best model for your request based on capacity and performance.\n\nCost may vary based on the selected model.", + scale: scale + ) + containerStack.addArrangedSubview(desc) + layoutAndShow(nearRect: nearRect, preferRight: preferRight, fontScale: fontScale) + return + } + + // --- Context Size section --- + let hasInput = model.maxInputTokens != nil + let hasOutput = model.maxOutputTokens != nil + if hasInput || hasOutput { + containerStack.addArrangedSubview(makeSeparator()) + + let inputStr = model.maxInputTokens.map { "\u{2191} \(ModelMenuItemFormatter.formatContextWindow($0))" } ?? "" + let outputStr = model.maxOutputTokens.map { "\u{2193} \(ModelMenuItemFormatter.formatContextWindow($0))" } ?? "" + let contextValue = [inputStr, outputStr].filter { !$0.isEmpty }.joined(separator: " ") + let row = makeKeyValueRow("Context Size:", contextValue, scale: scale) + containerStack.addArrangedSubview(row) + row.translatesAutoresizingMaskIntoConstraints = false + row.widthAnchor.constraint(equalTo: containerStack.widthAnchor).isActive = true + } + + // --- Cost / million tokens section --- + if let tokenPrices = model.billing?.tokenPrices { + containerStack.addArrangedSubview(makeSeparator()) + + if let category = model.modelPickerPriceCategory, !category.isEmpty { + let categoryRow = makeKeyValueRow("Cost Category:", category.capitalized, scale: scale) + containerStack.addArrangedSubview(categoryRow) + categoryRow.translatesAutoresizingMaskIntoConstraints = false + categoryRow.widthAnchor.constraint(equalTo: containerStack.widthAnchor).isActive = true + } + + let costHeader = NSTextField(labelWithString: "Cost per 1M Tokens:") + costHeader.font = NSFont.systemFont(ofSize: 12 * scale) + costHeader.textColor = .secondaryLabelColor + containerStack.addArrangedSubview(costHeader) + + let tokenUnit = tokenPrices.tokenUnit + + if let inputPrice = tokenPrices.inputPrice { + let row = makeKeyValueRow("Input:", formatPrice(inputPrice, tokenUnit: tokenUnit), scale: scale) + containerStack.addArrangedSubview(row) + row.translatesAutoresizingMaskIntoConstraints = false + row.widthAnchor.constraint(equalTo: containerStack.widthAnchor).isActive = true + } + if let outputPrice = tokenPrices.outputPrice { + let row = makeKeyValueRow("Output:", formatPrice(outputPrice, tokenUnit: tokenUnit), scale: scale) + containerStack.addArrangedSubview(row) + row.translatesAutoresizingMaskIntoConstraints = false + row.widthAnchor.constraint(equalTo: containerStack.widthAnchor).isActive = true + } + if let cachePrice = tokenPrices.cachePrice { + let row = makeKeyValueRow("Cached:", formatPrice(cachePrice, tokenUnit: tokenUnit), scale: scale) + containerStack.addArrangedSubview(row) + row.translatesAutoresizingMaskIntoConstraints = false + row.widthAnchor.constraint(equalTo: containerStack.widthAnchor).isActive = true + } + } + + // --- Context Window --- + if let maxContext = model.maxContextWindowTokens { + containerStack.addArrangedSubview(makeSeparator()) + + let row = makeKeyValueRow("Context Window:", "\(ModelMenuItemFormatter.formatContextWindow(maxContext))", scale: scale) + containerStack.addArrangedSubview(row) + row.translatesAutoresizingMaskIntoConstraints = false + row.widthAnchor.constraint(equalTo: containerStack.widthAnchor).isActive = true + } + + // --- Thinking Effort --- + if model.supportsReasoningEffortLevel, !model.isAutoModel { + let efforts = model.reasoningEfforts ?? [] + if !efforts.isEmpty { + containerStack.addArrangedSubview(makeSeparator()) + + let headerLabel = NSTextField(labelWithString: "Thinking Effort:") + headerLabel.font = NSFont.systemFont(ofSize: 12 * scale) + headerLabel.textColor = .secondaryLabelColor + containerStack.addArrangedSubview(headerLabel) + + let currentEffort = AppState.shared.effectiveReasoningEffort(for: model) ?? "" + let familyDefault = model.defaultReasoningEffort + + // Zero-spacing nested stack so container vPad doesn't add to inter-row gap + let effortsStack = NSStackView() + effortsStack.orientation = .vertical + effortsStack.alignment = .leading + effortsStack.spacing = 0 + effortsStack.translatesAutoresizingMaskIntoConstraints = false + containerStack.addArrangedSubview(effortsStack) + effortsStack.widthAnchor.constraint(equalTo: containerStack.widthAnchor).isActive = true + + for effort in efforts { + let isSelected = effort.lowercased() == currentEffort.lowercased() + let isDefault = effort.lowercased() == familyDefault + let row = makeThinkingEffortRow( + effort: effort, + isSelected: isSelected, + isDefault: isDefault, + scale: scale, + onSelect: { [weak self] in + AppState.shared.setSelectedReasoningEffort(effort, for: model) + let onModelSelect = self?.onModelSelect + DispatchQueue.main.async { [weak self] in + onModelSelect?() + self?.orderOut(nil) + } + } + ) + effortsStack.addArrangedSubview(row) + row.translatesAutoresizingMaskIntoConstraints = false + row.widthAnchor.constraint(equalTo: effortsStack.widthAnchor).isActive = true + } + } + } + + layoutAndShow(nearRect: nearRect, preferRight: preferRight, fontScale: fontScale) + startInteractivity() + } + + private func layoutAndShow(nearRect: NSRect, preferRight: Bool, fontScale: CGFloat) { + let horizontalPadding: CGFloat = 10 * fontScale + let verticalPadding: CGFloat = 8 * fontScale + let hasThinkingEffort = (currentModel?.supportsReasoningEffortLevel == true) + && !(currentModel?.reasoningEfforts?.isEmpty ?? true) + && !(currentModel?.isAutoModel ?? false) + let minPanelWidth: CGFloat = (hasThinkingEffort ? 320 : 220) * fontScale + let maxPanelWidth: CGFloat = 560 * fontScale + + containerStack.layoutSubtreeIfNeeded() + let fittingSize = containerStack.fittingSize + + let panelWidth = max(minPanelWidth, min(ceil(fittingSize.width + horizontalPadding * 2), maxPanelWidth)) + let contentWidth = panelWidth - horizontalPadding * 2 + + for view in containerStack.arrangedSubviews { + if let textField = view as? NSTextField { + let wraps = textField.cell?.wraps == true + let isTitleFont = textField.font?.pointSize == 13 * fontScale + + if isTitleFont { + textField.lineBreakMode = .byWordWrapping + textField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + textField.preferredMaxLayoutWidth = contentWidth + } else if wraps { + textField.preferredMaxLayoutWidth = contentWidth + } + } + } + + containerStack.layoutSubtreeIfNeeded() + let finalFittingSize = containerStack.fittingSize + let panelHeight = ceil(finalFittingSize.height + verticalPadding * 2) + + 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) + } + + let menuScreen = NSScreen.screens.first(where: { $0.frame.contains(nearRect.origin) }) ?? NSScreen.main + + 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 + } + origin.x = max(origin.x, screenFrame.minX) + origin.x = min(origin.x, screenFrame.maxX - panelWidth) + 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.3, repeats: false) { [weak self] _ in + guard let self = self else { return } + // Don't hide if mouse is still over the panel + if self.frame.contains(NSEvent.mouseLocation) { return } + self.stopInteractivity() + self.orderOut(nil) + } + } + + func cancelHide() { + hideTimer?.invalidate() + hideTimer = nil + } + + override func orderOut(_ sender: Any?) { + stopInteractivity() + super.orderOut(sender) + } + + override func close() { + hideTimer?.invalidate() + hideTimer = nil + stopInteractivity() + 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..9f7d87e9 --- /dev/null +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ModelPickerMenu.swift @@ -0,0 +1,427 @@ +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 = resolvedMultiplierText(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, + onModelSelect: { + AppState.shared.setSelectedModel(model) + menu.cancelTracking() + } + ) + }, + onHoverExit: { + self.detailPanel.scheduleHide() + } + ) + item.view = menuItemView + menu.addItem(item) + } + + private func resolvedMultiplierText(for model: LLMModel) -> String { + if model.supportsReasoningEffortLevel { + let effort = AppState.shared.effectiveReasoningEffort(for: model) + return ModelMenuItemFormatter.getMultiplierText(for: model, reasoningEffort: effort) + } + return currentCache.modelMultiplierCache[model.id.appending(model.providerName ?? "")] + ?? ModelMenuItemFormatter.getMultiplierText(for: model) + } + + private func calculateMaxWidth( + copilotModels: [LLMModel], + byokModels: [LLMModel] + ) -> CGFloat { + var maxWidth: CGFloat = 0 + let allModels = isBYOKFFEnabled ? copilotModels + byokModels : copilotModels + + for model in allModels { + let multiplierText = resolvedMultiplierText(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..0ddd1927 --- /dev/null +++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ModelPickerMenuItem.swift @@ -0,0 +1,310 @@ +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 + + if multiplierText.isEmpty { + // No multiplier — name label extends to the trailing edge + NSLayoutConstraint.activate([ + nameLabel.leadingAnchor.constraint( + equalTo: textLeading, constant: constants.checkmarkToText + ), + nameLabel.centerYAnchor.constraint(equalTo: centerYAnchor), + nameLabel.trailingAnchor.constraint( + lessThanOrEqualTo: trailingAnchor, + constant: -constants.trailingPadding + ), + ]) + } else { + NSLayoutConstraint.activate([ + nameLabel.leadingAnchor.constraint( + equalTo: textLeading, constant: constants.checkmarkToText + ), + nameLabel.centerYAnchor.constraint(equalTo: centerYAnchor), + + multiplierLabel.trailingAnchor.constraint( + equalTo: trailingAnchor, constant: -constants.trailingPadding + ), + multiplierLabel.centerYAnchor.constraint(equalTo: centerYAnchor), + + nameLabel.trailingAnchor.constraint( + lessThanOrEqualTo: multiplierLabel.leadingAnchor, + constant: -constants.nameToMultiplier + ), + ]) + } + } + + // MARK: - Mouse handling + + override func mouseUp(with _: NSEvent) { + onSelect() + } + + // MARK: - Keyboard selection + + /// Called by the menu's `performKeyEquivalent` when Return/Enter is pressed + /// while this item is highlighted. Custom-view menu items don't receive + /// the default NSMenu action, so the menu triggers selection explicitly. + func performSelect() { + onSelect() + } + + override var acceptsFirstResponder: Bool { true } + + override func keyDown(with event: NSEvent) { + let confirmKeyCodes: Set = [ + 36, // return + 76, // enter (numpad) + ] + if confirmKeyCodes.contains(event.keyCode) { + onSelect() + } else { + super.keyDown(with: event) + } + } + + // MARK: - Drawing (highlight driven by NSMenu) + + private func updateColors() { + let highlighted = isHighlighted + if highlighted { + nameLabel.textColor = .white + multiplierLabel.textColor = .white.withAlphaComponent(0.8) + checkmarkImageView.contentTintColor = .white + warningImageView.contentTintColor = .white + } else { + nameLabel.textColor = .labelColor + multiplierLabel.textColor = .secondaryLabelColor + checkmarkImageView.contentTintColor = .labelColor + warningImageView.contentTintColor = .labelColor + } + } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + + let highlighted = isHighlighted + + // Trigger detail panel on highlight change + if highlighted != wasHighlighted { + wasHighlighted = highlighted + if highlighted { + if let onHover = onHover { + let screenRect = + window?.convertToScreen(convert(bounds, to: nil)) ?? .zero + onHover(model, screenRect) + } + } else { + onHoverExit?() + } + } + + updateColors() + + if highlighted { + ModelMenuItemFormatter.drawMenuItemHighlight( + in: frame, + fontScale: fontScale, + hoverEdgeInset: constants.hoverEdgeInset + ) + } + } + + // MARK: - Width Calculation + + static func calculateItemWidth( + model: LLMModel, + multiplierText: String, + fontScale: Double + ) -> CGFloat { + let constants = LayoutConstants(fontScale: fontScale) + let font = NSFont.systemFont(ofSize: constants.fontSize, weight: .regular) + let attrs: [NSAttributedString.Key: Any] = [.font: font] + let displayName = model.displayName ?? model.modelName + let nameWidth = (displayName as NSString).size(withAttributes: attrs).width + + var width = constants.leadingPadding + constants.checkmarkSize + + constants.checkmarkToText + ceil(nameWidth) + constants.trailingPadding + + if !multiplierText.isEmpty { + let multWidth = ceil( + (multiplierText as NSString).size(withAttributes: attrs).width + ) + width += constants.nameToMultiplier + multWidth + } + + return width + } +} diff --git a/Core/Sources/ConversationTab/TerminalViews/RunInTerminalToolView.swift b/Core/Sources/ConversationTab/TerminalViews/RunInTerminalToolView.swift index 777b2cfc..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 @@ -155,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) @@ -170,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 727b45f3..8d75db57 100644 --- a/Core/Sources/ConversationTab/Views/BotMessage.swift +++ b/Core/Sources/ConversationTab/Views/BotMessage.swift @@ -21,6 +21,7 @@ struct BotMessage: View { var followUp: ConversationFollowUp? { message.followUp } var errorMessages: [String] { message.errorMessages } var steps: [ConversationProgressStep] { message.steps } + var thinking: [MessageThinking] { message.thinking } var editAgentRounds: [AgentRound] { message.editAgentRounds } var panelMessages: [CopilotShowMessageParams] { message.panelMessages } var codeReviewRound: CodeReviewRound? { message.codeReviewRound } @@ -90,9 +91,16 @@ struct BotMessage: View { // progress step if steps.count > 0 { ProgressStep(steps: steps) - + } - + + ForEach(Array(thinking.enumerated()), id: \.offset) { index, entry in + ThinkingView( + thinking: entry, + isStreaming: index == thinking.count - 1 && isThinkingStreaming() + ) + } + if !panelMessages.isEmpty { WithPerceptionTracking { ForEach(panelMessages.indices, id: \.self) { index in @@ -100,11 +108,11 @@ struct BotMessage: View { } } } - + if editAgentRounds.count > 0 { - ProgressAgentRound(rounds: editAgentRounds, chat: chat) + ProgressAgentRound(rounds: editAgentRounds, chat: chat, isStreaming: isThinkingStreaming()) } - + if !text.isEmpty { Group{ ThemedMarkdownText(text: text, chat: chat) @@ -126,7 +134,10 @@ struct BotMessage: View { HStack { if shouldShowTurnStatus() { - TurnStatusView(message: message) + TurnStatusView( + message: message, + isSummarizingConversation: chat.isSummarizingConversation + ) .modify { view in if message.turnStatus == .inProgress { view @@ -187,7 +198,7 @@ struct BotMessage: View { VStack(alignment: .leading, spacing: 4) { Text(attributedString) - if errorMessages[index] == HardCodedToolRoundExceedErrorMessage { + if isSettingsActionableError(errorMessages[index]) { Button(action: { Task { try? launchHostAppAdvancedSettings() @@ -205,6 +216,11 @@ struct BotMessage: View { .scaledPadding(.vertical, 4) } + private func isSettingsActionableError(_ message: String) -> Bool { + message == HardCodedToolRoundExceedErrorMessage || + message == SSLCertificateErrorMessage + } + private func shouldShowTurnStatus() -> Bool { guard isLatestAssistantMessage() else { return false @@ -233,17 +249,28 @@ struct BotMessage: View { let lastMessage = chat.history.last return lastMessage?.role == .assistant && lastMessage?.id == id } + + private func isThinkingStreaming() -> Bool { + guard isLatestAssistantMessage(), chat.isReceivingMessage else { return false } + switch message.turnStatus { + case .success, .error, .cancelled: return false + default: return true + } + } } private struct TurnStatusView: View { - + let message: DisplayedChatMessage - + let isSummarizingConversation: Bool + @AppStorage(\.chatFontSize) var chatFontSize - + var body: some View { HStack(spacing: 0) { - if let turnStatus = message.turnStatus { + if isSummarizingConversation { + summarizingStatus + } else if let turnStatus = message.turnStatus { switch turnStatus { case .inProgress: inProgressStatus @@ -266,12 +293,25 @@ private struct TurnStatusView: View { .controlSize(.small) .scaledScaleEffect(0.7) .scaledFrame(width: 16, height: 16) - + Text("Generating...") .scaledFont(size: chatFontSize - 1) .foregroundColor(.secondary) } } + + private var summarizingStatus: some View { + HStack(spacing: 4) { + ProgressView() + .controlSize(.small) + .scaledScaleEffect(0.7) + .scaledFrame(width: 16, height: 16) + + Text("Summarizing conversation...") + .scaledFont(size: chatFontSize - 1) + .foregroundColor(.secondary) + } + } private var completedStatus: some View { statusView(icon: "checkmark.circle.fill", iconColor: .successLightGreen, text: "Completed") diff --git a/Core/Sources/ConversationTab/Views/BotMessage/ResponseToolBar.swift b/Core/Sources/ConversationTab/Views/BotMessage/ResponseToolBar.swift index 108f2f64..fa9a0bb1 100644 --- a/Core/Sources/ConversationTab/Views/BotMessage/ResponseToolBar.swift +++ b/Core/Sources/ConversationTab/Views/BotMessage/ResponseToolBar.swift @@ -14,11 +14,13 @@ struct ResponseToolBar: View { return nil } let rounded = (multiplier * 100).rounded() / 100 + guard rounded != 0 else { return nil } let formatter = NumberFormatter() formatter.minimumFractionDigits = 0 formatter.maximumFractionDigits = 2 formatter.numberStyle = .decimal let formattedMultiplier = formatter.string(from: NSNumber(value: rounded)) ?? "\(rounded)" + guard rounded != 0 else { return nil } return "\(formattedMultiplier)x" } @@ -26,13 +28,21 @@ struct ResponseToolBar: View { guard let modelName = message.modelName else { return nil } - + var text = modelName - + + if let providerName = message.modelProviderName, !providerName.isEmpty { + text += " • \(providerName)" + } + + if let effort = message.reasoningEffort, !effort.isEmpty, effort.lowercased() != "none" { + text += " • \(effort.capitalized)" + } + if let billingMultiplier = billingMultiplier { text += " • \(billingMultiplier)" } - + return text } diff --git a/Core/Sources/ConversationTab/Views/ChatPanelInputArea/ChatPanelInputArea.swift b/Core/Sources/ConversationTab/Views/ChatPanelInputArea/ChatPanelInputArea.swift index 251f4022..346981ef 100644 --- a/Core/Sources/ConversationTab/Views/ChatPanelInputArea/ChatPanelInputArea.swift +++ b/Core/Sources/ConversationTab/Views/ChatPanelInputArea/ChatPanelInputArea.swift @@ -19,22 +19,15 @@ struct ChatPanelInputArea: View { Button(action: { chat.send(.clearButtonTap) }) { - Group { - if #available(macOS 13.0, *) { - Image(systemName: "eraser.line.dashed.fill") - .scaledFont(.body) - } else { - Image(systemName: "trash.fill") - .scaledFont(.body) + Image(systemName: "eraser.line.dashed.fill") + .scaledFont(.body) + .padding(6) + .background { + Circle().fill(Color(nsColor: .controlBackgroundColor)) + } + .overlay { + Circle().stroke(Color(nsColor: .controlColor), lineWidth: 1) } - } - .padding(6) - .background { - Circle().fill(Color(nsColor: .controlBackgroundColor)) - } - .overlay { - Circle().stroke(Color(nsColor: .controlColor), lineWidth: 1) - } } .buttonStyle(.plain) } diff --git a/Core/Sources/ConversationTab/Views/ChatPanelInputArea/ContextSizeButton.swift b/Core/Sources/ConversationTab/Views/ChatPanelInputArea/ContextSizeButton.swift new file mode 100644 index 00000000..fdf3be11 --- /dev/null +++ b/Core/Sources/ConversationTab/Views/ChatPanelInputArea/ContextSizeButton.swift @@ -0,0 +1,196 @@ +import ConversationServiceProvider +import SharedUIComponents +import SwiftUI + +struct ContextSizeButton: View { + let contextSizeInfo: ContextSizeInfo + @State private var isHovering = false + @State private var showPopover = false + @State private var isClickTriggered = false + @State private var hoverTask: Task? + @State private var dismissTask: Task? + + private let ringSize: CGFloat = 11 + private let lineWidth: CGFloat = 1.5 + + var body: some View { + Button(action: { + hoverTask?.cancel() + dismissTask?.cancel() + isClickTriggered = true + showPopover = true + }) { + HStack(spacing: 4) { + DonutChart( + percentage: contextSizeInfo.utilizationPercentage, + ringColor: ringColor, + size: ringSize, + lineWidth: lineWidth + ) + + if isHovering { + Text("\(Int(contextSizeInfo.utilizationPercentage))%") + .scaledFont(size: 11, weight: .medium) + .foregroundColor(.primary) + .transition(.opacity) + } + } + .scaledPadding(.horizontal, 6) + .scaledPadding(.vertical, 4) + } + .buttonStyle(HoverButtonStyle(padding: 0)) + .accessibilityLabel("Context size") + .accessibilityValue(Text("\(Int(contextSizeInfo.utilizationPercentage)) percent of context tokens used")) + .accessibilityHint("Shows details about the current context size and token usage.") + .animation(.easeInOut(duration: 0.15), value: isHovering) + .onHover { hovering in + isHovering = hovering + hoverTask?.cancel() + if hovering { + dismissTask?.cancel() + hoverTask = Task { + try? await Task.sleep(nanoseconds: 2_000_000_000) + guard !Task.isCancelled else { return } + isClickTriggered = false + showPopover = true + } + } else if !isClickTriggered { + scheduleDismiss() + } + } + .onChange(of: showPopover) { newValue in + if !newValue { + isClickTriggered = false + } + } + .popover(isPresented: $showPopover, arrowEdge: .bottom) { + ContextSizePopover(info: contextSizeInfo) + .onHover { hovering in + if hovering { + dismissTask?.cancel() + } else if !isClickTriggered { + scheduleDismiss() + } + } + } + } + + private func scheduleDismiss() { + dismissTask?.cancel() + dismissTask = Task { + try? await Task.sleep(nanoseconds: 200_000_000) + guard !Task.isCancelled else { return } + showPopover = false + } + } + + private var ringColor: Color { + let pct = contextSizeInfo.utilizationPercentage + if pct >= 80 { return Color("WarningYellow") } + return .secondary + } +} + +private struct DonutChart: View { + let percentage: Double + let ringColor: Color + let size: CGFloat + let lineWidth: CGFloat + + var body: some View { + ZStack { + Circle() + .stroke(Color(nsColor: .quaternaryLabelColor), lineWidth: lineWidth) + + Circle() + .trim(from: 0, to: min(percentage / 100, 1.0)) + .stroke(ringColor, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) + .rotationEffect(.degrees(-90)) + } + .scaledFrame(width: size, height: size) + } +} + +private struct ContextSizePopover: View { + let info: ContextSizeInfo + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + // MARK: Context Window + VStack(alignment: .leading, spacing: 6) { + Text("Context Window") + .scaledFont(.headline) + + HStack { + Text("\(formatTokens(info.totalUsedTokens)) / \(formatTokens(info.totalTokenLimit)) tokens") + Spacer() + Text(formatPercentage(info.utilizationPercentage)) + } + .scaledFont(.callout) + + ProgressView(value: min(info.utilizationPercentage, 100), total: 100) + .tint(progressColor) + } + + // MARK: System + VStack(alignment: .leading, spacing: 4) { + Text("System") + .scaledFont(.headline) + .scaledPadding(.bottom, 4) + + tokenRow("System Instructions", tokens: info.systemPromptTokens) + tokenRow("Tool Definitions", tokens: info.toolDefinitionTokens) + } + + // MARK: User + VStack(alignment: .leading, spacing: 4) { + Text("User") + .scaledFont(.headline) + .scaledPadding(.bottom, 4) + + tokenRow("Messages", tokens: info.userMessagesTokens + info.assistantMessagesTokens) + tokenRow("Attached Files", tokens: info.attachedFilesTokens) + tokenRow("Tool Results", tokens: info.toolResultsTokens) + } + + // TODO: Depends on CLS for manual compression + } + .scaledPadding(.vertical, 20) + .scaledPadding(.horizontal, 16) + .scaledFrame(width: 240) + } + + private var progressColor: Color { + let pct = info.utilizationPercentage + if pct >= 80 { return Color(nsColor: .systemYellow) } + return .accentColor + } + + private func tokenRow(_ label: String, tokens: Int) -> some View { + HStack { + Text(label) + Spacer() + Text(percentage(for: tokens)) + } + .scaledFont(.callout) + } + + private func percentage(for tokens: Int) -> String { + guard info.totalTokenLimit > 0 else { return "0%" } + let pct = Double(tokens) / Double(info.totalTokenLimit) * 100 + return formatPercentage(pct) + } + + private func formatPercentage(_ pct: Double) -> String { + if pct == 0 { return "0%" } + return String(format: "%.1f%%", pct) + } + + private func formatTokens(_ count: Int) -> String { + if count >= 1000 { + let k = Double(count) / 1000.0 + return String(format: "%.1fK", k) + } + return "\(count)" + } +} diff --git a/Core/Sources/ConversationTab/Views/ChatPanelInputArea/InputAreaTextEditor.swift b/Core/Sources/ConversationTab/Views/ChatPanelInputArea/InputAreaTextEditor.swift index 88373b42..0659dbf8 100644 --- a/Core/Sources/ConversationTab/Views/ChatPanelInputArea/InputAreaTextEditor.swift +++ b/Core/Sources/ConversationTab/Views/ChatPanelInputArea/InputAreaTextEditor.swift @@ -173,13 +173,18 @@ struct InputAreaTextEditor: View { ModeAndModelPicker(projectRootURL: projectRootURL, selectedAgent: $chat.selectedAgent) Spacer() - - if chat.editorMode.isDefault { + + if let contextSizeInfo = chat.contextSizeInfo { + ContextSizeButton(contextSizeInfo: contextSizeInfo) + .padding(.trailing, 4) + } + + if chat.editorMode.isDefault && !isRequestingConversation { codeReviewButton .buttonStyle(HoverButtonStyle(padding: 0, hoverColor: .clear)) - .opacity(isRequestingConversation ? 0 : 1) + .padding(.trailing, 4) } - + ZStack { sendButton .opacity(isRequestingConversation || isRequestingCodeReview ? 0 : 1) diff --git a/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ConversationAgentProgressView.swift b/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ConversationAgentProgressView.swift index b6fc3e4c..f002f643 100644 --- a/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ConversationAgentProgressView.swift +++ b/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ConversationAgentProgressView.swift @@ -1,21 +1,34 @@ -import SwiftUI -import ConversationServiceProvider -import ComposableArchitecture -import Combine -import ChatTab 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 isStreaming: Bool = false + var body: some View { WithPerceptionTracking { VStack(alignment: .leading, spacing: 8) { - ForEach(rounds, id: \.roundId) { round in + ForEach(Array(rounds.enumerated()), id: \.element.roundId) { roundIndex, round in + let isLastRound = roundIndex == rounds.count - 1 VStack(alignment: .leading, spacing: 8) { - ThemedMarkdownText(text: round.reply, chat: chat) + ForEach(Array(round.thinking.enumerated()), id: \.offset) { entryIndex, entry in + ThinkingView( + thinking: entry, + isStreaming: isStreaming + && isLastRound + && entryIndex == round.thinking.count - 1 + ) + } + if !round.reply.isEmpty { + ThemedMarkdownText(text: round.reply, chat: chat) + } if let toolCalls = round.toolCalls, !toolCalls.isEmpty { ProgressToolCalls(tools: toolCalls, chat: chat) } @@ -33,15 +46,20 @@ struct ProgressAgentRound: View { 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) + ForEach(Array(round.thinking.enumerated()), id: \.offset) { _, entry in + ThinkingView(thinking: entry, isStreaming: false) + } + if !round.reply.isEmpty { + ThemedMarkdownText(text: round.reply, chat: chat) + } if let toolCalls = round.toolCalls, !toolCalls.isEmpty { ProgressToolCalls(tools: toolCalls, chat: chat) } @@ -59,12 +77,12 @@ struct SubAgentRounds: View { 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 { + 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) @@ -85,6 +103,216 @@ struct ToolConfirmationView: View { @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) { @@ -104,15 +332,8 @@ struct ToolConfirmationView: View { Text(tool.isToolcallingLoopContinueTool ? "Cancel" : "Skip") .scaledFont(.body) } - - Button(action: { - chat.send(.toolCallAccepted(tool.id)) - }) { - Text(tool.isToolcallingLoopContinueTool ? "Continue" : "Allow") - .scaledFont(.body) - } - .buttonStyle(BorderedProminentButtonStyle()) - + + confirmationActionView } .frame(maxWidth: .infinity, alignment: .leading) .scaledPadding(.top, 4) @@ -132,7 +353,7 @@ struct ToolConfirmationTitleView: View { var fontWeight: Font.Weight = .regular @AppStorage(\.chatFontSize) var chatFontSize - + var body: some View { HStack(spacing: 4) { Text(title) @@ -180,23 +401,56 @@ struct GenericToolTitleView: View { struct ProgressAgentRound_Preview: PreviewProvider { static let agentRounds: [AgentRound] = [ .init(roundId: 1, reply: "this is agent step", toolCalls: [ + // Completed read file .init( id: "toolcall_001", - name: "Tool Call 1", - progressMessage: "Read Tool Call 1", - status: .completed, - error: nil), + name: ServerToolName.readFile.rawValue, + progressMessage: "Read src/AppDelegate.swift", + status: .completed), + // Completed file search with results .init( id: "toolcall_002", - name: "Tool Call 2", - progressMessage: "Running Tool Call 2", - status: .running) - ]) - ] + name: ServerToolName.findFiles.rawValue, + progressMessage: "Searched for files matching query: **/*.swift", + status: .completed, + resultDetails: [ + .fileLocation(.init(uri: "file:///src/App.swift", range: .init(start: .init(line: 0, character: 0), end: .init(line: 10, character: 0)))), + .fileLocation(.init(uri: "file:///src/Model.swift", range: .init(start: .init(line: 0, character: 0), end: .init(line: 5, character: 0)))), + .fileLocation(.init(uri: "file:///src/ViewModel.swift", range: .init(start: .init(line: 0, character: 0), end: .init(line: 8, character: 0)))), + ]), + // Completed create file (expandable) + .init( + id: "toolcall_003", + name: ToolName.createFile.rawValue, + progressMessage: "Created src/NewFeature.swift", + status: .completed, + result: [.text("```swift\nstruct NewFeature {\n var name: String\n}\n```")]), + // Completed replace string (expandable) + .init( + id: "toolcall_004", + name: ServerToolName.replaceString.rawValue, + progressMessage: "Edited src/Config.swift", + status: .completed, + result: [.text("```diff\n- let version = \"1.0\"\n+ let version = \"2.0\"\n```")]), + // Running tool + .init( + id: "toolcall_005", + name: ServerToolName.codebase.rawValue, + progressMessage: "Searching codebase for references", + status: .running), + // Error tool + .init( + id: "toolcall_006", + name: ServerToolName.readFile.rawValue, + progressMessage: "Read missing_file.swift", + status: .error, + error: "File not found"), + ]), + ] 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) + .frame(width: 400, height: 500) } } diff --git a/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ToolStatusItemView.swift b/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ToolStatusItemView.swift index 9238a932..fc87e3bb 100644 --- a/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ToolStatusItemView.swift +++ b/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ToolStatusItemView.swift @@ -510,22 +510,22 @@ private struct ToolStatusDetailsView: View { @AppStorage(\.fontScale) var fontScale var body: some View { - VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: 2) { Button(action: { isExpanded.toggle() }) { - HStack(spacing: 8) { + HStack(spacing: 2) { title - Spacer() - Image(systemName: isExpanded ? "chevron.down" : "chevron.right") .resizable() .scaledToFit() .padding(4) .scaledFrame(width: 16, height: 16) .scaledFont(size: 10, weight: .medium) + + Spacer() } .contentShape(RoundedRectangle(cornerRadius: 6)) } @@ -534,9 +534,6 @@ private struct ToolStatusDetailsView: View { .toolStatusStyle(withBackground: !isExpanded, fontScale: fontScale) if isExpanded { - Divider() - .background(Color.agentToolStatusDividerColor) - content .scaledPadding(.horizontal, 8) } @@ -552,10 +549,6 @@ private extension View { if withBackground { view .scaledPadding(.vertical, 6) - .background( - RoundedRectangle(cornerRadius: 6) - .stroke(Color.agentToolStatusOutlineColor, lineWidth: 1 * fontScale) - ) } else { view } @@ -578,3 +571,64 @@ private extension View { } } } + +// MARK: - Preview + +struct ToolStatusItemView_Preview: PreviewProvider { + static var previews: some View { + VStack(alignment: .leading, spacing: 4) { + // Completed read file + ToolStatusItemView(tool: .init( + id: "1", + name: ServerToolName.readFile.rawValue, + progressMessage: "Read src/AppDelegate.swift", + status: .completed + )) + // Completed file search + ToolStatusItemView(tool: .init( + id: "2", + name: ServerToolName.findFiles.rawValue, + progressMessage: "Searched for files matching query: **/*.swift", + status: .completed, + resultDetails: [ + .fileLocation(.init(uri: "file:///src/App.swift", range: .init(start: .init(line: 0, character: 0), end: .init(line: 10, character: 0)))), + .fileLocation(.init(uri: "file:///src/Model.swift", range: .init(start: .init(line: 0, character: 0), end: .init(line: 5, character: 0)))), + ] + )) + // Completed create file (expandable) + ToolStatusItemView(tool: .init( + id: "3", + name: ToolName.createFile.rawValue, + progressMessage: "Created src/NewFeature.swift", + status: .completed, + result: [.text("struct NewFeature {\n var name: String\n}")] + )) + // Completed replace string (expandable) + ToolStatusItemView(tool: .init( + id: "4", + name: ServerToolName.replaceString.rawValue, + progressMessage: "Edited src/Config.swift", + status: .completed, + result: [.text("- let version = \"1.0\"\n+ let version = \"2.0\"")] + )) + // Running + ToolStatusItemView(tool: .init( + id: "5", + name: ServerToolName.codebase.rawValue, + progressMessage: "Searching codebase", + status: .running + )) + // Error + ToolStatusItemView(tool: .init( + id: "6", + name: ServerToolName.readFile.rawValue, + progressMessage: "Read missing_file.swift", + status: .error, + error: "File not found" + )) + } + .padding() + .frame(width: 400) + .colorScheme(.dark) + } +} diff --git a/Core/Sources/ConversationTab/Views/FunctionMessage.swift b/Core/Sources/ConversationTab/Views/FunctionMessage.swift index 0523a44e..5686f0e9 100644 --- a/Core/Sources/ConversationTab/Views/FunctionMessage.swift +++ b/Core/Sources/ConversationTab/Views/FunctionMessage.swift @@ -24,21 +24,43 @@ struct FunctionMessage: View { text.contains("You've reached your quota limit for your BYOK model") } + private var isTBBMessage: Bool { + text.contains("AI Credits") || text.contains("additional overages") + } + private var switchToFallbackModelText: String { + guard !isTBBMessage else { return "" } if let fallbackModelName = CopilotModelManager.getFallbackLLM( scope: chat.isAgentMode ? .agentPanel : .chatPanel )?.modelName { return "We have automatically switched you to \(fallbackModelName) which is included with your plan." - } else { - return "" } + return "" + } + + private var quotaActionButtons: [(title: String, urlString: String, isProminent: Bool)] { + let lower = text.lowercased() + let hasEnableOverage = lower.contains("enable additional overages") + let hasIncreaseBudget = lower.contains("increase budget") + let hasOverage = hasEnableOverage || hasIncreaseBudget + var buttons: [(String, String, Bool)] = [] + if hasEnableOverage { + buttons.append(("Enable Additional Overage", "https://aka.ms/github-copilot-manage-overage", true)) + } + if hasIncreaseBudget { + buttons.append(("Increase Budget", "https://aka.ms/github-copilot-manage-overage", true)) + } + if lower.contains("upgrade your plan") { + buttons.append(("Upgrade Plan", "https://aka.ms/github-copilot-upgrade-plan", !hasOverage)) + } + return buttons } private var errorContent: Text { switch (isFreePlanUser, isOrgUser, isBYOKUser) { case (true, _, _): return Text("Monthly message limit reached. Upgrade to Copilot Pro (30-day free trial) or wait for your limit to reset.") - + case (_, true, _): let parts = [ "You have exceeded your free request allowance.", @@ -46,17 +68,11 @@ struct FunctionMessage: View { "To enable additional paid premium requests, contact your organization admin." ].filter { !$0.isEmpty } return Text(attributedString(from: parts)) - + case (_, _, true): let sentences = splitBYOKQuotaMessage(text) - guard sentences.count == 2 else { fallthrough } - - let parts = [ - sentences[0], - switchToFallbackModelText, - sentences[1] - ].filter { !$0.isEmpty } + let parts = [sentences[0], switchToFallbackModelText, sentences[1]].filter { !$0.isEmpty } return Text(attributedString(from: parts)) default: @@ -91,7 +107,7 @@ struct FunctionMessage: View { var body: some View { NotificationBanner(style: .warning) { errorContent - + if isFreePlanUser { Button("Update to Copilot Pro") { if let url = URL(string: "https://aka.ms/github-copilot-upgrade-plan") { @@ -109,6 +125,27 @@ struct FunctionMessage: View { } } } + + if !quotaActionButtons.isEmpty { + HStack(spacing: 8) { + ForEach(quotaActionButtons, id: \.title) { button in + Group { + if button.isProminent { + Button(button.title) { + if let url = URL(string: button.urlString) { openURL(url) } + }.buttonStyle(.borderedProminent) + } else { + Button(button.title) { + if let url = URL(string: button.urlString) { openURL(url) } + }.buttonStyle(.bordered) + } + } + .controlSize(.regular) + .scaledFont(.body) + .onHover { if $0 { NSCursor.pointingHand.push() } else { NSCursor.pop() } } + } + } + } } } } diff --git a/Core/Sources/ConversationTab/Views/NotificationBanner.swift b/Core/Sources/ConversationTab/Views/NotificationBanner.swift index f5047793..89062b0a 100644 --- a/Core/Sources/ConversationTab/Views/NotificationBanner.swift +++ b/Core/Sources/ConversationTab/Views/NotificationBanner.swift @@ -2,16 +2,19 @@ import SwiftUI import SharedUIComponents public enum BannerStyle { + case info case warning var iconName: String { switch self { - case .warning: return "exclamationmark.triangle" + case .info: return "info.circle.fill" + case .warning: return "exclamationmark.triangle.fill" } } var color: Color { switch self { + case .info: return .blue case .warning: return .orange } } @@ -19,6 +22,8 @@ public enum BannerStyle { struct NotificationBanner: View { var style: BannerStyle + var isDismissable: Bool = false + var onDismiss: (() -> Void)? = nil @ViewBuilder var content: () -> Content @AppStorage(\.chatFontSize) var chatFontSize @@ -31,15 +36,25 @@ struct NotificationBanner: View { VStack(alignment: .leading, spacing: 8) { content() } + .frame(maxWidth: .infinity, alignment: .leading) + + if isDismissable { + Button(action: { onDismiss?() }) { + Image(systemName: "xmark") + .foregroundColor(.secondary) + } + .buttonStyle(HoverButtonStyle()) + } } .scaledFont(size: chatFontSize - 1) } .frame(maxWidth: .infinity, alignment: .topLeading) .scaledPadding(.vertical, 10) .scaledPadding(.horizontal, 12) + .background(Color("BannerBackgroundColor")) .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + RoundedRectangle(cornerRadius: 8) + .stroke(Color("BannerBorderColor"), lineWidth: 1) ) } } diff --git a/Core/Sources/ConversationTab/Views/ThemedMarkdownText.swift b/Core/Sources/ConversationTab/Views/ThemedMarkdownText.swift index 086d724e..f9a1409b 100644 --- a/Core/Sources/ConversationTab/Views/ThemedMarkdownText.swift +++ b/Core/Sources/ConversationTab/Views/ThemedMarkdownText.swift @@ -174,15 +174,14 @@ struct MarkdownCodeBlockView: View { struct ThemedMarkdownText_Previews: PreviewProvider { static var previews: some View { - let chatTabInfo = ChatTabInfo(id: "id", workspacePath: "path", username: "name") ThemedMarkdownText( - text:""" - ```swift - let sumClosure: (Int, Int) -> Int = { (a: Int, b: Int) in - return a + b - } - ``` - """, - context: .init(onInsert: {_ in print("Inserted") })) + text: """ + ```swift + let sumClosure: (Int, Int) -> Int = { (a: Int, b: Int) in + return a + b + } + ``` + """, + context: .init(onInsert: { _ in print("Inserted") })) } } diff --git a/Core/Sources/ConversationTab/Views/ThinkingView.swift b/Core/Sources/ConversationTab/Views/ThinkingView.swift new file mode 100644 index 00000000..777bf7e2 --- /dev/null +++ b/Core/Sources/ConversationTab/Views/ThinkingView.swift @@ -0,0 +1,144 @@ +import SwiftUI +import ComposableArchitecture +import ConversationServiceProvider +import SharedUIComponents + +struct ThinkingView: View { + let thinking: MessageThinking + let isStreaming: Bool + + @AppStorage(\.chatFontSize) var chatFontSize + @State private var isExpandedOverride: Bool? = nil + + private var sections: [ThinkingSection] { + MessageThinking.parseSections(from: thinking.text?.joined() ?? "") + } + + private var titleText: String { + if isStreaming { + return "Thinking..." + } + if let title = thinking.title, !title.isEmpty { + return title + } + return "Thinking" + } + + private var isExpanded: Bool { + if let override = isExpandedOverride { return override } + return isStreaming + } + + private var isAutoExpandedWhileStreaming: Bool { + isStreaming && isExpandedOverride == nil + } + + private static let autoExpandMaxHeight: CGFloat = 180 + private static let scrollAnchorID = "thinking-bottom-anchor" + + var body: some View { + WithPerceptionTracking { + let sections = sections + let hasContent = sections.contains { $0.title != nil || !$0.body.isEmpty } + if hasContent || isStreaming { + content(sections: sections, hasContent: hasContent) + } + } + } + + @ViewBuilder + private func content(sections: [ThinkingSection], hasContent: Bool) -> some View { + VStack(alignment: .leading, spacing: 8) { + Button { + isExpandedOverride = !isExpanded + } label: { + HStack(spacing: 2) { + Text(titleText) + .scaledFont(size: chatFontSize - 1) + .lineLimit(1) + .truncationMode(.tail) + Image(systemName: isExpanded ? "chevron.down" : "chevron.right") + .resizable() + .scaledToFit() + .padding(4) + .scaledFrame(width: 16, height: 16) + .scaledFont(size: 10, weight: .medium) + } + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + + if isExpanded, hasContent { + sectionsContainer(sections: sections) + } + } + } + + @ViewBuilder + private func sectionsContainer(sections: [ThinkingSection]) -> some View { + let stack = VStack(alignment: .leading, spacing: 8) { + ForEach(Array(sections.enumerated()), id: \.offset) { _, section in + sectionView(section) + } + Color.clear + .frame(height: 0) + .id(Self.scrollAnchorID) + } + .fixedSize(horizontal: false, vertical: true) + + if isAutoExpandedWhileStreaming { + ScrollViewReader { proxy in + ScrollView(.vertical, showsIndicators: false) { + stack + } + .frame(maxHeight: Self.autoExpandMaxHeight) + .onChange(of: thinking.text?.joined() ?? "") { _ in + withAnimation(.easeOut(duration: 0.15)) { + proxy.scrollTo(Self.scrollAnchorID, anchor: .bottom) + } + } + .onAppear { + proxy.scrollTo(Self.scrollAnchorID, anchor: .bottom) + } + } + } else { + stack + } + } + + @ViewBuilder + private func sectionView(_ section: ThinkingSection) -> some View { + HStack(alignment: .top, spacing: 8) { + VStack(spacing: 4) { + Circle() + .fill(Color.secondary.opacity(0.3)) + .frame(width: 4, height: 4) + .padding(.top, 6) + Rectangle() + .fill(Color.secondary.opacity(0.3)) + .frame(width: 1) + .frame(maxHeight: .infinity) + } + .frame(width: 4) + + VStack(alignment: .leading, spacing: 4) { + if let title = section.title, !title.isEmpty { + Text(title) + .scaledFont(size: chatFontSize - 1) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + if !section.body.isEmpty { + ThemedMarkdownText( + text: section.body, + context: MarkdownActionProvider(supportInsert: false), + foregroundColor: .secondary + ) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .fixedSize(horizontal: false, vertical: true) + } +} diff --git a/Core/Sources/ConversationTab/Views/WarningBanner.swift b/Core/Sources/ConversationTab/Views/WarningBanner.swift new file mode 100644 index 00000000..ae2dcdaa --- /dev/null +++ b/Core/Sources/ConversationTab/Views/WarningBanner.swift @@ -0,0 +1,72 @@ +import AppKit +import GitHubCopilotService +import SharedUIComponents +import SwiftUI + +struct WarningBanner: View { + let message: String + let severity: String // "warning" or "info" + let actions: [WarningAction] + let onDismiss: () -> Void + + @State private var hoveredActionIndex: Int? = nil + + private var bannerStyle: BannerStyle { + severity == "warning" ? .warning : .info + } + + var body: some View { + NotificationBanner(style: bannerStyle, isDismissable: true, onDismiss: onDismiss) { + VStack(alignment: .leading, spacing: 8) { + Text(message) + .foregroundColor(.primary) + .frame(maxWidth: .infinity, alignment: .leading) + + if !actions.isEmpty { + HStack(spacing: 12) { + ForEach(Array(actions.enumerated()), id: \.offset) { index, action in + ActionLink( + title: action.title, + url: action.url, + isHovered: hoveredActionIndex == index + ) { isHovered in + hoveredActionIndex = isHovered ? index : nil + } + } + } + } + } + } + } +} + +private struct ActionLink: View { + let title: String + let url: URL + let isHovered: Bool + let onHoverChange: (Bool) -> Void + + var body: some View { + Button(action: { + NSWorkspace.shared.open(url) + }) { + Text(title) + .underline(isHovered) + .foregroundColor(.accentColor) + } + .buttonStyle(.plain) + .onHover { isHovered in + onHoverChange(isHovered) + DispatchQueue.main.async { + if isHovered { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } + } + .onDisappear { + NSCursor.pop() + } + } +} diff --git a/Core/Sources/ConversationTab/Views/WorkingSetView.swift b/Core/Sources/ConversationTab/Views/WorkingSetView.swift index 6120af78..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") diff --git a/Core/Sources/GitHubCopilotViewModel/GitHubCopilotViewModel.swift b/Core/Sources/GitHubCopilotViewModel/GitHubCopilotViewModel.swift index 39d298c0..52062f58 100644 --- a/Core/Sources/GitHubCopilotViewModel/GitHubCopilotViewModel.swift +++ b/Core/Sources/GitHubCopilotViewModel/GitHubCopilotViewModel.swift @@ -5,6 +5,7 @@ import Status import SwiftUI import Cache import Client +import Logger public struct SignInResponse { public let status: SignInInitiateStatus @@ -30,6 +31,9 @@ public class GitHubCopilotViewModel: ObservableObject { @Published public var waitingForSignIn = false static var copilotAuthService: GitHubCopilotService? + + private var lastQuotaCheckDate: Date? + private static let quotaCacheInterval: TimeInterval = 60 // seconds // Make init private to enforce singleton pattern private init() {} @@ -363,4 +367,23 @@ public class GitHubCopilotViewModel: ObservableObject { object: nil ) } + + /// Refreshes quota info only if the cache has expired (older than 60s). + public func refreshQuotaIfNeeded() { + if let lastCheck = lastQuotaCheckDate, + Date().timeIntervalSince(lastCheck) < Self.quotaCacheInterval { + return + } + Task { + do { + let service = try getGitHubCopilotAuthService() + let accountStatus = try await service.checkStatus() + guard accountStatus == .ok || accountStatus == .maybeOk else { return } + let _ = try await service.checkQuota() + lastQuotaCheckDate = Date() + } catch { + Logger.client.error("Failed to refresh quota: \(error)") + } + } + } } diff --git a/Core/Sources/HostApp/AdvancedSettings/ChatSection.swift b/Core/Sources/HostApp/AdvancedSettings/ChatSection.swift index 988a086e..a8910979 100644 --- a/Core/Sources/HostApp/AdvancedSettings/ChatSection.swift +++ b/Core/Sources/HostApp/AdvancedSettings/ChatSection.swift @@ -36,35 +36,31 @@ struct ChatSection: View { .padding(SettingsToggle.defaultPadding) Divider() + + } - if featureFlags.isAgentModeEnabled && copilotPolicy.isCustomAgentEnabled { - // Custom Agents - .github/agents/*.agent.md - AgentFileSetting(promptType: .agent) - .padding(SettingsToggle.defaultPadding) - - Divider() - - // SubAgent toggle - SettingsToggle( - title: "Enable Subagent", - subtitle: "Allows Copilot Agent mode to call custom agents as subagent. Requires GitHub Copilot for Xcode restart to take effect.", - isOn: Binding( - get: { enableSubagent && copilotPolicy.isSubagentEnabled }, - set: { if copilotPolicy.isSubagentEnabled { enableSubagent = $0 } } - ), - badge: copilotPolicy.isSubagentEnabled - ? nil - : BadgeItem( - text: "Disabled by organization policy", - level: .warning, - icon: "exclamationmark.triangle.fill", - tooltip: "Subagents are disabled by your organization's policy. Please contact your administrator to enable them." - ) - ) - .disabled(!copilotPolicy.isSubagentEnabled) + if featureFlags.isAgentModeEnabled && copilotPolicy.isCustomAgentEnabled { + // Custom Agents - .github/agents/*.agent.md + AgentFileSetting(promptType: .agent) + .padding(SettingsToggle.defaultPadding) - Divider() - } + Divider() + + // SubAgent toggle + SettingsToggle( + title: "Enable Subagent", + subtitle: "Allows Copilot Agent mode to call custom agents as subagent. Requires GitHub Copilot for Xcode restart to take effect.", + isOn: Binding( + get: { enableSubagent && copilotPolicy.isSubagentEnabled }, + set: { if copilotPolicy.isSubagentEnabled { enableSubagent = $0 } } + ), + badge: copilotPolicy.isSubagentEnabled + ? nil + : .disabledByPolicy(feature: "Subagents", isPlural: true) + ) + .disabled(!copilotPolicy.isSubagentEnabled) + + Divider() } // Auto Attach toggle @@ -93,13 +89,18 @@ struct ChatSection: View { FontSizeSetting() .padding(SettingsToggle.defaultPadding) + Divider() + if featureFlags.isAgentModeEnabled { - Divider() - // Agent Max Tool Calling Requests AgentMaxToolCallLoopSetting() .padding(SettingsToggle.defaultPadding) + + Divider() } + + // Auto Compress + AgentAutoCompressSetting() } } } @@ -341,6 +342,26 @@ struct AgentMaxToolCallLoopSetting: View { } } +struct AgentAutoCompressSetting: View { + @AppStorage(\.autoCompress) var autoCompress + + var body: some View { + SettingsToggle( + title: "Auto Compress", + subtitle: "Automatically compact the conversation history to save contect tokens.", + isOn: Binding( + get: { autoCompress }, + set: { + autoCompress = $0 + DistributedNotificationCenter + .default() + .post(name: .githubCopilotAgentAutoCompressDidChange, object: nil) + } + ) + ) + } +} + struct CopilotInstructionSetting: View { @State var isGlobalInstructionsViewOpen = false @Environment(\.toast) var toast diff --git a/Core/Sources/HostApp/AdvancedSettings/DisabledLanguageList.swift b/Core/Sources/HostApp/AdvancedSettings/DisabledLanguageList.swift index d869b9ca..2ccbdd71 100644 --- a/Core/Sources/HostApp/AdvancedSettings/DisabledLanguageList.swift +++ b/Core/Sources/HostApp/AdvancedSettings/DisabledLanguageList.swift @@ -5,13 +5,8 @@ import SharedUIComponents extension List { @ViewBuilder func removeBackground() -> some View { - if #available(macOS 13.0, *) { - scrollContentBackground(.hidden) - .listRowBackground(EmptyView()) - } else { - background(Color.clear) - .listRowBackground(EmptyView()) - } + scrollContentBackground(.hidden) + .listRowBackground(EmptyView()) } } @@ -80,11 +75,7 @@ struct DisabledLanguageList: View { } } .modify { view in - if #available(macOS 13.0, *) { - view.listRowSeparator(.hidden).listSectionSeparator(.hidden) - } else { - view - } + view.listRowSeparator(.hidden).listSectionSeparator(.hidden) } } .removeBackground() diff --git a/Core/Sources/HostApp/BYOKSettings/ProviderConfigView.swift b/Core/Sources/HostApp/BYOKSettings/ProviderConfigView.swift index 937086f5..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 diff --git a/Core/Sources/HostApp/CopilotPolicyManager.swift b/Core/Sources/HostApp/CopilotPolicyManager.swift index cc769117..9cf22eff 100644 --- a/Core/Sources/HostApp/CopilotPolicyManager.swift +++ b/Core/Sources/HostApp/CopilotPolicyManager.swift @@ -17,7 +17,8 @@ public class CopilotPolicyManager: ObservableObject { @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() @@ -74,7 +75,8 @@ public class CopilotPolicyManager: ObservableObject { 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)") diff --git a/Core/Sources/HostApp/FeatureFlagManager.swift b/Core/Sources/HostApp/FeatureFlagManager.swift index e996a8db..189d5a4e 100644 --- a/Core/Sources/HostApp/FeatureFlagManager.swift +++ b/Core/Sources/HostApp/FeatureFlagManager.swift @@ -19,7 +19,8 @@ public class FeatureFlagManager: ObservableObject { @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() @@ -78,7 +79,8 @@ public class FeatureFlagManager: ObservableObject { 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)") 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 011b5fed..7c0b2e03 100644 --- a/Core/Sources/HostApp/SharedComponents/Badge.swift +++ b/Core/Sources/HostApp/SharedComponents/Badge.swift @@ -105,3 +105,17 @@ struct Badge: View { .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/DisclosureSettingsRow.swift b/Core/Sources/HostApp/SharedComponents/DisclosureSettingsRow.swift index b033c1dc..6567985c 100644 --- a/Core/Sources/HostApp/SharedComponents/DisclosureSettingsRow.swift +++ b/Core/Sources/HostApp/SharedComponents/DisclosureSettingsRow.swift @@ -23,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/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/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/ToolsConfigView.swift b/Core/Sources/HostApp/ToolsConfigView.swift index 6308b5b4..6ece9ade 100644 --- a/Core/Sources/HostApp/ToolsConfigView.swift +++ b/Core/Sources/HostApp/ToolsConfigView.swift @@ -19,33 +19,32 @@ struct MCPConfigView: View { @State private var isMonitoring: Bool = false @State private var lastModificationDate: Date? = nil @State private var fileMonitorTask: Task? = nil - @State private var selectedOption = ToolType.MCP @State private var selectedMode: ConversationMode = .defaultAgent @Environment(\.colorScheme) var colorScheme - + private var isCustomAgentEnabled: Bool { - featureFlags.isEditorPreviewEnabled && copilotPolicy.isCustomAgentEnabled + 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) @@ -55,16 +54,18 @@ struct MCPConfigView: View { .padding(.bottom, 4) Group { - if selectedOption == .MCP { + if hostAppStore.activeToolsSubTab == .MCP { VStack(alignment: .leading, spacing: 8) { MCPIntroView(isMCPFFEnabled: featureFlags.isMCPEnabled) if featureFlags.isMCPEnabled { MCPManualInstallView() - if featureFlags.isEditorPreviewEnabled && ( SystemUtils.isPrereleaseBuild || SystemUtils.isDeveloperMode ) { + if featureFlags.isEditorPreviewEnabled { MCPRegistryURLView() } + MCPXcodeServerInstallView() + MCPToolsListView( selectedMode: $selectedMode, isCustomAgentEnabled: isCustomAgentEnabled @@ -100,11 +101,13 @@ struct MCPConfigView: View { selectedMode = .defaultAgent } } - } else { + } else if hostAppStore.activeToolsSubTab == .BuiltIn { BuiltInToolsListView( selectedMode: $selectedMode, isCustomAgentEnabled: isCustomAgentEnabled ) + } else { + AutoApproveContainerView() } } .padding(.horizontal, 20) @@ -177,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") @@ -187,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) 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/MCPManualInstallView.swift b/Core/Sources/HostApp/ToolsSettings/MCPManualInstallView.swift index 80ea7589..6909b851 100644 --- a/Core/Sources/HostApp/ToolsSettings/MCPManualInstallView.swift +++ b/Core/Sources/HostApp/ToolsSettings/MCPManualInstallView.swift @@ -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 c54712ca..52b1264b 100644 --- a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryInstallation.swift +++ b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryInstallation.swift @@ -90,9 +90,55 @@ public class MCPRegistryService: ObservableObject { public static let shared = MCPRegistryService() public static let apiVersion = "v0.1" @AppStorage(\.mcpRegistryBaseURL) var mcpRegistryBaseURL - + @Published public private(set) var mcpRegistryEntries: [MCPRegistryEntry]? + private init() {} + /// Fetches the MCP registry allowlist from the language server and updates + /// ``mcpRegistryEntries``. Safe to call from any view's `onAppear` – + /// duplicate in-flight calls are coalesced via the `isRefreshing` flag. + private var isRefreshing = false + + public func refreshAllowlist() async { + guard !isRefreshing else { return } + isRefreshing = true + defer { isRefreshing = false } + + do { + let service = try getService() + + let authStatus = try await service.getXPCServiceAuthStatus() + guard authStatus?.status == .loggedIn else { + Logger.client.info("User not logged in, skipping MCP registry allowlist fetch") + mcpRegistryEntries = nil + return + } + + let result = try await service.getMCPRegistryAllowlist() + + guard let result = result, !result.mcpRegistries.isEmpty else { + if result == nil { + Logger.client.error("Failed to get allowlist result") + } else { + mcpRegistryEntries = [] + } + return + } + + if let firstRegistry = result.mcpRegistries.first { + let entry = MCPRegistryEntry( + url: firstRegistry.url, + registryAccess: firstRegistry.registryAccess, + owner: firstRegistry.owner + ) + mcpRegistryEntries = [entry] + Logger.client.info("Current MCP Registry Entry: \(entry)") + } + } catch { + Logger.client.error("Failed to get MCP allowlist from registry: \(error)") + } + } + public static func getServerName(from serverDetail: MCPRegistryServerDetail) -> String { return serverDetail.name } @@ -304,7 +350,7 @@ public class MCPRegistryService: ObservableObject { // Save configuration let jsonData = try JSONSerialization.data(withJSONObject: config, options: [.prettyPrinted]) - try jsonData.write(to: configFileURL) + try jsonData.write(to: configFileURL, options: .atomic) // Note: UserDefaults update and notification will be handled by ToolsConfigView's file monitor // with debouncing to prevent duplicate notifications diff --git a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryURLView.swift b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryURLView.swift index 6661b93f..e01b90c5 100644 --- a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryURLView.swift +++ b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryURLView.swift @@ -14,7 +14,7 @@ struct MCPRegistryURLView: View { @State private var isLoading: Bool = false @State private var tempURLText: String = "" @State private var errorMessage: String = "" - @State private var mcpRegistry: [MCPRegistryEntry]? = nil + @ObservedObject private var registryService = MCPRegistryService.shared private let maxURLLength = 2048 private let mcpRegistryUrlVersion = "/v0.1/servers" @@ -48,7 +48,7 @@ struct MCPRegistryURLView: View { } .buttonStyle(.bordered) .help("Configure your MCP Registry Base URL") - .disabled(mcpRegistry?.first?.registryAccess == .registryOnly) + .disabled(registryService.mcpRegistryEntries?.first?.registryAccess == .registryOnly) Button { Task{ await loadMCPServers() } } label: { HStack(spacing: 0) { @@ -74,7 +74,7 @@ struct MCPRegistryURLView: View { urlText: $tempURLText, maxURLLength: maxURLLength, isSheet: false, - mcpRegistryEntry: mcpRegistry?.first, + mcpRegistryEntry: registryService.mcpRegistryEntries?.first, onValidationChange: { _ in // Only validate, don't update mcpRegistryURL here }, @@ -102,15 +102,7 @@ struct MCPRegistryURLView: View { } } } - .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 = mcpRegistryBaseURL Task { await getMCPRegistryAllowlist() } @@ -123,7 +115,7 @@ struct MCPRegistryURLView: View { tempURLText = newValue Task { await updateGalleryWindowIfOpen() } } - .onChange(of: mcpRegistry) { _ in + .onChange(of: registryService.mcpRegistryEntries) { _ in Task { await updateGalleryWindowIfOpen() } } } @@ -153,7 +145,7 @@ struct MCPRegistryURLView: View { mcpRegistryBaseURLHistory.addToHistory(mcpRegistryBaseURL) errorMessage = "" - MCPServerGalleryWindow.open(serverList: serverList, mcpRegistryEntry: mcpRegistry?.first) + MCPServerGalleryWindow.open(serverList: serverList, mcpRegistryEntry: registryService.mcpRegistryEntries?.first) } catch { Logger.client.error("Failed to load MCP servers from registry: \(error.localizedDescription)") if let serviceError = error as? XPCExtensionServiceError { @@ -168,44 +160,14 @@ struct MCPRegistryURLView: View { private func getMCPRegistryAllowlist() async { isLoading = true defer { isLoading = false } - do { - let service = try getService() - - // Only fetch allowlist if user is logged in - let authStatus = try await service.getXPCServiceAuthStatus() - guard authStatus?.status == .loggedIn else { - Logger.client.info("User not logged in, skipping MCP registry allowlist fetch") - return - } - - let result = try await service.getMCPRegistryAllowlist() - - guard let result = result, !result.mcpRegistries.isEmpty else { - if result == nil { - Logger.client.error("Failed to get allowlist result") - } else { - mcpRegistry = [] - } - return - } - - if let firstRegistry = result.mcpRegistries.first { - let entry = MCPRegistryEntry( - url: firstRegistry.url, - registryAccess: firstRegistry.registryAccess, - owner: firstRegistry.owner - ) - mcpRegistry = [entry] - Logger.client.info("Current MCP Registry Entry: \(entry)") - - // If registryOnly, force the URL to be the registry URL - if entry.registryAccess == .registryOnly { - mcpRegistryBaseURL = entry.url - tempURLText = entry.url - } - } - } catch { - Logger.client.error("Failed to get MCP allowlist from registry: \(error)") + + await registryService.refreshAllowlist() + + // If registryOnly, force the URL to be the registry URL + if let entry = registryService.mcpRegistryEntries?.first, + entry.registryAccess == .registryOnly { + mcpRegistryBaseURL = entry.url + tempURLText = entry.url } } @@ -219,7 +181,7 @@ struct MCPRegistryURLView: View { defer { isLoading = false } // Let the view model handle the entire update flow including clearing and fetching - if let error = await MCPServerGalleryWindow.refreshFromURL(mcpRegistryEntry: mcpRegistry?.first) { + if let error = await MCPServerGalleryWindow.refreshFromURL(mcpRegistryEntry: registryService.mcpRegistryEntries?.first) { // Display error in the URL view if let serviceError = error as? XPCExtensionServiceError { errorMessage = serviceError.underlyingError?.localizedDescription ?? serviceError.localizedDescription diff --git a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerDetailSheet.swift b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerDetailSheet.swift index e5533fc5..b462519a 100644 --- a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerDetailSheet.swift +++ b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerDetailSheet.swift @@ -4,10 +4,9 @@ import GitHubCopilotService import SharedUIComponents import Foundation -@available(macOS 13.0, *) struct MCPServerDetailSheet: View { let server: MCPRegistryServerDetail - let meta: ServerMeta + let meta: ServerMeta? @State private var selectedTab = TabType.Packages @State private var expandedPackages: Set = [] @State private var expandedRemotes: Set = [] @@ -101,8 +100,8 @@ struct MCPServerDetailSheet: View { Text(server.title ?? server.name) .font(.system(size: 18, weight: .semibold)) - if meta.official.status == .deprecated { - statusBadge(meta.official.status) + if let status = meta?.official?.status, status == .deprecated { + statusBadge(status) } Spacer() @@ -115,10 +114,14 @@ struct MCPServerDetailSheet: View { } .font(.system(size: 12, design: .monospaced)) .foregroundColor(.secondary) - - dateMetadataTag(title: "Published ", dateString: meta.official.publishedAt, image: "clock.arrow.trianglehead.counterclockwise.rotate.90") - dateMetadataTag(title: "Updated ", dateString: meta.official.updatedAt, image: "icloud.and.arrow.up") + if let publishedAt = meta?.official?.publishedAt { + dateMetadataTag(title: "Published ", dateString: publishedAt, image: "clock.arrow.trianglehead.counterclockwise.rotate.90") + } + + if let updatedAt = meta?.official?.updatedAt { + dateMetadataTag(title: "Updated ", dateString: updatedAt, image: "icloud.and.arrow.up") + } if let repo = server.repository, !repo.url.isEmpty, !repo.source.isEmpty { if let repoURL = URL(string: repo.url) { @@ -175,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) } @@ -292,7 +297,9 @@ struct MCPServerDetailSheet: View { private var metadataTab: some View { VStack(alignment: .leading, spacing: 16) { - officialMetadataSection(meta.official) + if let officialMeta = meta?.official { + officialMetadataSection(officialMeta) + } } } @@ -304,18 +311,23 @@ struct MCPServerDetailSheet: View { } VStack(alignment: .leading, spacing: 8) { - 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) diff --git a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerGalleryView.swift b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerGalleryView.swift index f8b7ba03..0082b480 100644 --- a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerGalleryView.swift +++ b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerGalleryView.swift @@ -287,34 +287,28 @@ struct MCPServerGalleryView: View { .buttonStyle(DestructiveButtonStyle()) .help("Uninstall") } else { - if #available(macOS 13.0, *) { - SplitButton( - title: "Install", - isDisabled: viewModel.hasNoDeployments(response.server), - primaryAction: { - // Install with default configuration - Task { - await viewModel.installServer(response.server) - } - }, - menuItems: viewModel.getInstallationOptions(for: response.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(response.server, configuration: option.displayName) } } } - ) - .help("Install") - } else { - Button("Install") { - Task { - await viewModel.installServer(response.server) - } - } - .disabled(viewModel.hasNoDeployments(response.server)) - .help("Install") - } + }() + ) + .help("Install") } Button { @@ -336,11 +330,7 @@ struct MCPServerGalleryView: View { } private func infoSheet(_ response: MCPRegistryServerResponse) -> some View { - if #available(macOS 13.0, *) { - return AnyView(MCPServerDetailSheet(response: response)) - } else { - return AnyView(EmptyView()) - } + MCPServerDetailSheet(response: response) } } diff --git a/Core/Sources/HostApp/ToolsSettings/MCPXcodeServerInstallView.swift b/Core/Sources/HostApp/ToolsSettings/MCPXcodeServerInstallView.swift new file mode 100644 index 00000000..ccf83061 --- /dev/null +++ b/Core/Sources/HostApp/ToolsSettings/MCPXcodeServerInstallView.swift @@ -0,0 +1,234 @@ +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 + @ObservedObject private var registryService = MCPRegistryService.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 + } + + private var isRegistryOnly: Bool { + registryService.mcpRegistryEntries?.first?.registryAccess == .registryOnly + } + + 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() + Task { await registryService.refreshAllowlist() } + } + .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 isRegistryOnly { + Text("Manual installation of Xcode's built-in MCP server is blocked by your organization's registry policy. Please check the MCP Registry for an approved installation option, or contact your enterprise IT administrator.") + } 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 isRegistryOnly { + EmptyView() + } 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/KeyBindingManager/TabToAcceptSuggestion.swift b/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift index 07568796..4ebfe1a2 100644 --- a/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift +++ b/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift @@ -262,7 +262,7 @@ extension TabToAcceptSuggestion { return (false, "No filespace", nil) } - var codeSuggestionType: CodeSuggestionType? = { + let codeSuggestionType: CodeSuggestionType? = { if let _ = filespace.presentingSuggestion { return .codeCompletion } if let _ = filespace.presentingNESSuggestion { return .nes } return nil 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 d1837411..8d73d1d3 100644 --- a/Core/Sources/PersistMiddleware/Extensions/ChatMessage+Storage.swift +++ b/Core/Sources/PersistMiddleware/Extensions/ChatMessage+Storage.swift @@ -15,6 +15,7 @@ extension ChatMessage { var suggestedTitle: String? var errorMessages: [String] = [] var steps: [ConversationProgressStep] + var thinking: [MessageThinking] var editAgentRounds: [AgentRound] var parentTurnId: String? var panelMessages: [CopilotShowMessageParams] @@ -22,7 +23,9 @@ extension ChatMessage { var turnStatus: ChatMessage.TurnStatus? let requestType: RequestType var modelName: String? + var modelProviderName: String? var billingMultiplier: Float? + var reasoningEffort: String? // Custom decoder to provide default value for steps init(from decoder: Decoder) throws { @@ -35,6 +38,14 @@ extension ChatMessage { suggestedTitle = try container.decodeIfPresent(String.self, forKey: .suggestedTitle) errorMessages = try container.decodeIfPresent([String].self, forKey: .errorMessages) ?? [] steps = try container.decodeIfPresent([ConversationProgressStep].self, forKey: .steps) ?? [] + // Decode thinking as either an array (current format) or a single value (legacy format). + if let array = try? container.decodeIfPresent([MessageThinking].self, forKey: .thinking) { + thinking = array + } else if let single = try? container.decodeIfPresent(MessageThinking.self, forKey: .thinking) { + thinking = [single] + } else { + thinking = [] + } editAgentRounds = try container.decodeIfPresent([AgentRound].self, forKey: .editAgentRounds) ?? [] parentTurnId = try container.decodeIfPresent(String.self, forKey: .parentTurnId) panelMessages = try container.decodeIfPresent([CopilotShowMessageParams].self, forKey: .panelMessages) ?? [] @@ -42,7 +53,9 @@ extension ChatMessage { 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) + modelProviderName = try container.decodeIfPresent(String.self, forKey: .modelProviderName) billingMultiplier = try container.decodeIfPresent(Float.self, forKey: .billingMultiplier) + reasoningEffort = try container.decodeIfPresent(String.self, forKey: .reasoningEffort) } // Default memberwise init for encoding @@ -55,6 +68,7 @@ extension ChatMessage { suggestedTitle: String?, errorMessages: [String] = [], steps: [ConversationProgressStep]?, + thinking: [MessageThinking] = [], editAgentRounds: [AgentRound]? = nil, parentTurnId: String? = nil, panelMessages: [CopilotShowMessageParams]? = nil, @@ -62,7 +76,9 @@ extension ChatMessage { turnStatus: ChatMessage.TurnStatus? = nil, requestType: RequestType = .conversation, modelName: String? = nil, - billingMultiplier: Float? = nil + modelProviderName: String? = nil, + billingMultiplier: Float? = nil, + reasoningEffort: String? = nil ) { self.content = content self.contentImageReferences = contentImageReferences ?? [] @@ -72,6 +88,7 @@ extension ChatMessage { self.suggestedTitle = suggestedTitle self.errorMessages = errorMessages self.steps = steps ?? [] + self.thinking = thinking self.editAgentRounds = editAgentRounds ?? [] self.parentTurnId = parentTurnId self.panelMessages = panelMessages ?? [] @@ -79,7 +96,9 @@ extension ChatMessage { self.turnStatus = turnStatus self.requestType = requestType self.modelName = modelName + self.modelProviderName = modelProviderName self.billingMultiplier = billingMultiplier + self.reasoningEffort = reasoningEffort } } @@ -93,6 +112,7 @@ extension ChatMessage { suggestedTitle: self.suggestedTitle, errorMessages: self.errorMessages, steps: self.steps, + thinking: self.thinking, editAgentRounds: self.editAgentRounds, parentTurnId: self.parentTurnId, panelMessages: self.panelMessages, @@ -100,7 +120,9 @@ extension ChatMessage { turnStatus: self.turnStatus, requestType: self.requestType, modelName: self.modelName, - billingMultiplier: self.billingMultiplier + modelProviderName: self.modelProviderName, + billingMultiplier: self.billingMultiplier, + reasoningEffort: self.reasoningEffort ) // TODO: handle exception @@ -133,13 +155,16 @@ extension ChatMessage { rating: turnItemData.rating, steps: turnItemData.steps, editAgentRounds: turnItemData.editAgentRounds, + thinking: turnItemData.thinking, parentTurnId: turnItemData.parentTurnId, panelMessages: turnItemData.panelMessages, fileEdits: turnItemData.fileEdits, turnStatus: turnItemData.turnStatus, requestType: turnItemData.requestType, modelName: turnItemData.modelName, + modelProviderName: turnItemData.modelProviderName, billingMultiplier: turnItemData.billingMultiplier, + reasoningEffort: turnItemData.reasoningEffort, createdAt: turnItem.createdAt, updatedAt: turnItem.updatedAt ) diff --git a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift index 6c8b50b8..6b8d0094 100644 --- a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift +++ b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift @@ -366,10 +366,10 @@ public final class GraphicalUserInterfaceController { init() { @Dependency(\.workspacePool) var workspacePool + @Dependency(\.workspaceInvoker) var workspaceInvoker let chatTabPool = ChatTabPool() let suggestionDependency = SuggestionWidgetControllerDependency() - let workspaceInvoker = WorkspaceInvoker() let setupDependency: (inout DependencyValues) -> Void = { dependencies in dependencies.suggestionWidgetControllerDependency = suggestionDependency dependencies.suggestionWidgetUserDefaultsObservers = .init() @@ -386,7 +386,6 @@ public final class GraphicalUserInterfaceController { } } } - dependencies.workspaceInvoker = workspaceInvoker } let store = StoreOf( initialState: .init(), diff --git a/Core/Sources/Service/RealtimeSuggestionController.swift b/Core/Sources/Service/RealtimeSuggestionController.swift index b3fd109a..d583d792 100644 --- a/Core/Sources/Service/RealtimeSuggestionController.swift +++ b/Core/Sources/Service/RealtimeSuggestionController.swift @@ -88,16 +88,9 @@ public actor RealtimeSuggestionController { ) } - if #available(macOS 13.0, *) { - for await _ in selectedTextChanged._throttle(for: .milliseconds(200)) { - if Task.isCancelled { return } - await handler() - } - } else { - for await _ in selectedTextChanged { - if Task.isCancelled { return } - await handler() - } + for await _ in selectedTextChanged._throttle(for: .milliseconds(200)) { + if Task.isCancelled { return } + await handler() } } diff --git a/Core/Sources/SuggestionWidget/AgentConfigurationWidgetView.swift b/Core/Sources/SuggestionWidget/AgentConfigurationWidgetView.swift index fd423990..c6275778 100644 --- a/Core/Sources/SuggestionWidget/AgentConfigurationWidgetView.swift +++ b/Core/Sources/SuggestionWidget/AgentConfigurationWidgetView.swift @@ -239,7 +239,7 @@ struct AgentConfigurationWidgetView: View { } private func parseYAMLFrontmatter(content: String) -> YAMLFrontmatterInfo { - var lines = content.components(separatedBy: .newlines) + let lines = content.components(separatedBy: .newlines) var inFrontmatter = false var frontmatterEndIndex: Int? var modelLineIndex: Int? @@ -1063,7 +1063,8 @@ struct AgentConfigurationWidgetView: View { Text(createModelMenuItemAttributedString( modelName: model.displayName ?? model.modelName, isSelected: isModelSelected(model), - multiplierText: modelCache[model.modelName] ?? "Variable" + multiplierText: modelCache[model.modelName] ?? "Variable", + isDegraded: model.degradationReason != nil )) } @@ -1078,7 +1079,8 @@ struct AgentConfigurationWidgetView: View { Text(createModelMenuItemAttributedString( modelName: model.displayName ?? model.modelName, isSelected: isModelSelected(model), - multiplierText: modelCache[model.modelName] ?? "" + multiplierText: modelCache[model.modelName] ?? "", + isDegraded: model.degradationReason != nil )) } } @@ -1173,13 +1175,15 @@ struct AgentConfigurationWidgetView: View { private func createModelMenuItemAttributedString( modelName: String, isSelected: Bool, - multiplierText: String + multiplierText: String, + isDegraded: Bool = false ) -> AttributedString { return ModelMenuItemFormatter.createModelMenuItemAttributedString( modelName: modelName, isSelected: isSelected, multiplierText: multiplierText, targetWidth: targetMenuItemWidth, + isDegraded: isDegraded ) } } diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift index 665ccd70..f5530b28 100644 --- a/Core/Sources/SuggestionWidget/ChatWindowView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift @@ -232,17 +232,14 @@ struct ChatTitleBar: View { private extension View { func hideScrollIndicator() -> some View { - if #available(macOS 13.0, *) { - return scrollIndicators(.hidden) - } else { - return self - } + scrollIndicators(.hidden) } } struct ChatBar: View { let store: StoreOf @Binding var isChatHistoryVisible: Bool + @ObservedObject private var statusObserver = StatusObserver.shared struct TabBarState: Equatable { var tabInfo: IdentifiedArray @@ -258,6 +255,13 @@ struct ChatBar: View { Spacer() + if statusObserver.quotaInfo != nil { + QuotaButton(store: store) + + Divider() + .scaledFrame(height: 16) + } + CreateButton(store: store) ChatHistoryButton(store: store, isChatHistoryVisible: $isChatHistoryVisible) @@ -392,6 +396,165 @@ struct ChatBar: View { } } } + + struct QuotaButton: View { + let store: StoreOf + @ObservedObject private var statusObserver = StatusObserver.shared + @State private var isPopoverPresented = false + @State private var isButtonHovered = false + @State private var isPopoverHovered = false + @State private var dismissTask: DispatchWorkItem? + + private var quotaInfo: GitHubCopilotQuotaInfo? { + statusObserver.quotaInfo + } + + /// Static icon for unlimited business/enterprise; everyone else gets a dynamic pie chart. + private var usesStaticIcon: Bool { + guard let info = quotaInfo else { return true } + return info.isCBCEUnlimited + } + + /// Free plan uses chat percentRemaining; other plans use premiumInteractions. + private var pieChartPercentRemaining: Float? { + guard let info = quotaInfo else { return nil } + if info.isFreeUser { + if let p = info.chat.percentRemaining { return p } + return info.chat.usedPercentage.map { 100.0 - $0 } + } + guard let snapshot = info.premiumInteractions else { return nil } + if let p = snapshot.percentRemaining { return p } + return snapshot.usedPercentage.map { 100.0 - $0 } + } + + var body: some View { + WithPerceptionTracking { + Button(action: {}) { + if usesStaticIcon { + Image(systemName: "chart.pie") + .scaledFont(.body) + } else { + PieChartIcon( + percentRemaining: pieChartPercentRemaining ?? 100 + ) + .scaledFrame(width: 14, height: 14) + } + } + .buttonStyle(HoverButtonStyle()) + .accessibilityLabel("Copilot Usage") + .onHover { hovering in + isButtonHovered = hovering + handleHoverChange() + if hovering { + GitHubCopilotViewModel.shared.refreshQuotaIfNeeded() + } + } + .popover(isPresented: $isPopoverPresented, arrowEdge: .bottom) { + QuotaPopoverView( + quotaInfo: statusObserver.quotaInfo + ) + .onHover { hovering in + isPopoverHovered = hovering + handleHoverChange() + } + } + } + } + + private func handleHoverChange() { + dismissTask?.cancel() + if isButtonHovered || isPopoverHovered { + isPopoverPresented = true + // Activate the app so buttons in the popover are interactive + // even when the chat panel wasn't focused before hovering + if !NSApp.isActive { + if #available(macOS 14.0, *) { + NSApp.activate() + } else { + NSApp.activate(ignoringOtherApps: false) + } + } + } else { + let task = DispatchWorkItem { [self] in + if !isButtonHovered && !isPopoverHovered { + isPopoverPresented = false + } + } + dismissTask = task + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2, execute: task) + } + } + } + + /// A custom pie/donut chart icon that shows usage as a filled arc. + struct PieChartIcon: View { + let percentRemaining: Float + + private var usedFraction: Double { + Double(min(max(100 - percentRemaining, 0), 100)) / 100.0 + } + + private var color: Color { + if percentRemaining <= 10 { return .red } + if percentRemaining <= 25 { return .yellow } + return .primary + } + + var body: some View { + ZStack { + if percentRemaining <= 0 { + DonutShape(outerRadius: 13, innerRadius: 2) + .fill(color, style: FillStyle(eoFill: true)) + } else { + Circle() + .strokeBorder(color, lineWidth: 1) + PieSlice(fraction: usedFraction) + .fill(color) + } + } + } + + private struct DonutShape: Shape { + var outerRadius: CGFloat + var innerRadius: CGFloat + + func path(in rect: CGRect) -> Path { + let center = CGPoint(x: rect.midX, y: rect.midY) + let maxRadius = min(rect.width, rect.height) / 2 + let outer = min(outerRadius, maxRadius) + let inner = min(innerRadius, outer) + var path = Path() + path.addEllipse(in: CGRect( + x: center.x - outer, y: center.y - outer, + width: outer * 2, height: outer * 2 + )) + path.addEllipse(in: CGRect( + x: center.x - inner, y: center.y - inner, + width: inner * 2, height: inner * 2 + )) + return path + } + } + + private struct PieSlice: Shape { + var fraction: Double + + func path(in rect: CGRect) -> Path { + let center = CGPoint(x: rect.midX, y: rect.midY) + let radius = min(rect.width, rect.height) / 2 + let startAngle = Angle.degrees(-90) + let endAngle = Angle.degrees(-90 + 360 * fraction) + + var path = Path() + path.move(to: center) + path.addArc(center: center, radius: radius, + startAngle: startAngle, endAngle: endAngle, + clockwise: false) + path.closeSubpath() + return path + } + } + } } struct ChatTabBarButton: View { diff --git a/Core/Sources/SuggestionWidget/Extensions/Helper.swift b/Core/Sources/SuggestionWidget/Extensions/Helper.swift index 9e52c3c3..b59276f7 100644 --- a/Core/Sources/SuggestionWidget/Extensions/Helper.swift +++ b/Core/Sources/SuggestionWidget/Extensions/Helper.swift @@ -12,7 +12,7 @@ struct LocationStrategyHelper { with lines: [String], length: Int? = nil ) -> CGRect? { - guard editor.isSourceEditor, + guard editor.isNonNavigatorSourceEditor, lineNumber < lines.count && lineNumber >= 0 else { return nil diff --git a/Core/Sources/SuggestionWidget/Extensions/WidgetWindowsController+AgentConfigurationWidget.swift b/Core/Sources/SuggestionWidget/Extensions/WidgetWindowsController+AgentConfigurationWidget.swift index 6493f842..d68f297c 100644 --- a/Core/Sources/SuggestionWidget/Extensions/WidgetWindowsController+AgentConfigurationWidget.swift +++ b/Core/Sources/SuggestionWidget/Extensions/WidgetWindowsController+AgentConfigurationWidget.swift @@ -23,7 +23,7 @@ extension WidgetWindowsController { let state = store.withState { $0.panelState.agentConfigurationWidgetState } guard let noFocus = noFocus, !noFocus, - let focusedEditor = state.focusedEditor + state.focusedEditor != nil else { hideAgentConfigurationWidgetWindow() return 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/CodeReviewFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/CodeReviewFeature.swift index ed7b4375..56ed2632 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/CodeReviewFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/CodeReviewFeature.swift @@ -135,7 +135,7 @@ public struct CodeReviewPanelFeature { return .none - case let .close(id): + case .close(_): state.isPanelDisplayed = false state.closedByUser = true diff --git a/Core/Sources/SuggestionWidget/NES/NESMenu/NESMenuController.swift b/Core/Sources/SuggestionWidget/NES/NESMenu/NESMenuController.swift index 071b1dd2..5ce61b0e 100644 --- a/Core/Sources/SuggestionWidget/NES/NESMenu/NESMenuController.swift +++ b/Core/Sources/SuggestionWidget/NES/NESMenu/NESMenuController.swift @@ -48,7 +48,6 @@ class NESMenuController: ObservableObject { let settingsItem = createSettingItem() let goToAcceptItem = createGoToAcceptItem() let rejectItem = createRejectItem() - let moreInfoItem = createGetMoreInfoItem() menu.addItem(titleItem) menu.addItem(NSMenuItem.separator()) @@ -56,8 +55,6 @@ class NESMenuController: ObservableObject { menu.addItem(NSMenuItem.separator()) menu.addItem(goToAcceptItem) menu.addItem(rejectItem) -// menu.addItem(NSMenuItem.separator()) -// menu.addItem(moreInfoItem) self.menu = menu return menu diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift index 682d9c79..9462717f 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift @@ -332,13 +332,7 @@ extension PromptToCodePanel { codeForegroundColor: codeForegroundColor ) } - .modify { - if #available(macOS 13.0, *) { - $0.scrollIndicators(.hidden) - } else { - $0 - } - } + .scrollIndicators(.hidden) } } } diff --git a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift index e790da75..e3d19b8a 100644 --- a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift +++ b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift @@ -240,42 +240,24 @@ private extension WidgetWindowsController { let valueChange = await editor.axNotifications.notifications() .filter { $0.kind == .valueChanged } - if #available(macOS 13.0, *) { - for await notification in merge( - scroll, - selectionRangeChange.debounce(for: Duration.milliseconds(0)), - valueChange.debounce(for: Duration.milliseconds(100)) - ) { - guard await xcodeInspector.safe.latestActiveXcode != nil else { return } - try Task.checkCancellation() - - // for better looking - if notification.kind == .scrollPositionChanged { - await hideSuggestionPanelWindow() - } + for await notification in merge( + scroll, + selectionRangeChange.debounce(for: Duration.milliseconds(0)), + valueChange.debounce(for: Duration.milliseconds(100)) + ) { + guard await xcodeInspector.safe.latestActiveXcode != nil else { return } + try Task.checkCancellation() - updateWindowLocation(animated: false, immediately: false) - updateWindowOpacity(immediately: false) - await updateCodeReviewWindowLocation(.onSourceEditorNotification(notification)) - - await handleFixErrorEditorNotification(notification: notification) + // for better looking + if notification.kind == .scrollPositionChanged { + await hideSuggestionPanelWindow() } - } else { - for await notification in merge(selectionRangeChange, scroll, valueChange) { - guard await xcodeInspector.safe.latestActiveXcode != nil else { return } - try Task.checkCancellation() - // for better looking - if notification.kind == .scrollPositionChanged { - await hideSuggestionPanelWindow() - } + updateWindowLocation(animated: false, immediately: false) + updateWindowOpacity(immediately: false) + await updateCodeReviewWindowLocation(.onSourceEditorNotification(notification)) - updateWindowLocation(animated: false, immediately: false) - updateWindowOpacity(immediately: false) - await updateCodeReviewWindowLocation(.onSourceEditorNotification(notification)) - - await handleFixErrorEditorNotification(notification: notification) - } + await handleFixErrorEditorNotification(notification: notification) } } } @@ -862,8 +844,8 @@ extension WidgetWindowsController { guard state.isPanelDisplayed, let comment = state.currentSelectedComment, await currentXcodeApp?.realtimeDocumentURL?.absoluteString == comment.uri, - let reviewWindowFittingSize = windows.codeReviewPanelWindow.contentView?.fittingSize - else { + windows.codeReviewPanelWindow.contentView?.fittingSize != nil + else { hideCodeReviewWindow() return } @@ -871,7 +853,7 @@ extension WidgetWindowsController { guard let originalContent = state.originalContent, let screen = NSScreen.screens.first(where: { $0.frame.origin == .zero }), let scrollViewRect = sourceEditorElement.parent?.rect, - let scrollScreenFrame = sourceEditorElement.parent?.maxIntersectionScreen?.frame, + sourceEditorElement.parent?.maxIntersectionScreen?.frame != nil, let currentContent: String = try? sourceEditorElement.copyValue(key: kAXValueAttribute) else { return } 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/ExtensionService/AppDelegate+Menu.swift b/ExtensionService/AppDelegate+Menu.swift index a40b2137..bc99ed6a 100644 --- a/ExtensionService/AppDelegate+Menu.swift +++ b/ExtensionService/AppDelegate+Menu.swift @@ -111,23 +111,13 @@ extension AppDelegate { quotaItem = NSMenuItem() quotaItem.view = QuotaView( - chat: .init( - percentRemaining: 0, - unlimited: false, - overagePermitted: false - ), - completions: .init( - percentRemaining: 0, - unlimited: false, - overagePermitted: false - ), - premiumInteractions: .init( - percentRemaining: 0, - unlimited: false, - overagePermitted: false - ), - resetDate: "", - copilotPlan: "" + quotaInfo: GitHubCopilotQuotaInfo( + chat: .init(unlimited: false, overagePermitted: false), + completions: .init(unlimited: false, overagePermitted: false), + premiumInteractions: .init(unlimited: false, overagePermitted: false), + resetDate: "", + copilotPlan: "" + ) ) quotaItem.isHidden = true diff --git a/ExtensionService/AppDelegate.swift b/ExtensionService/AppDelegate.swift index 4995aa31..fcbad9d6 100644 --- a/ExtensionService/AppDelegate.swift +++ b/ExtensionService/AppDelegate.swift @@ -282,8 +282,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { let service = try await GitHubCopilotViewModel.shared.getGitHubCopilotAuthService() let accountStatus = try await service.checkStatus() if accountStatus == .ok || accountStatus == .maybeOk { - let quota = try await service.checkQuota() - Logger.service.info("User quota checked successfully: \(quota)") + await GitHubCopilotViewModel.shared.refreshQuotaIfNeeded() } } catch { Logger.service.error("Failed to read auth status: \(error)") @@ -333,27 +332,9 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { self.authStatusItem.isHidden = true } - if let quotaInfo = status.quotaInfo, !quotaInfo.resetDate.isEmpty { + if let quotaInfo = status.quotaInfo { self.quotaItem.isHidden = false - self.quotaItem.view = QuotaView( - chat: .init( - percentRemaining: quotaInfo.chat.percentRemaining, - unlimited: quotaInfo.chat.unlimited, - overagePermitted: quotaInfo.chat.overagePermitted - ), - completions: .init( - percentRemaining: quotaInfo.completions.percentRemaining, - unlimited: quotaInfo.completions.unlimited, - overagePermitted: quotaInfo.completions.overagePermitted - ), - premiumInteractions: .init( - percentRemaining: quotaInfo.premiumInteractions.percentRemaining, - unlimited: quotaInfo.premiumInteractions.unlimited, - overagePermitted: quotaInfo.premiumInteractions.overagePermitted - ), - resetDate: quotaInfo.resetDate, - copilotPlan: quotaInfo.copilotPlan - ) + self.quotaItem.view = QuotaView(quotaInfo: quotaInfo) } else { self.quotaItem.isHidden = true } @@ -535,6 +516,7 @@ enum CLSMessageType { case chatLimitReached case completionLimitReached case byokLimitedReached + case monthlyAICreditsLimitReached case other var summary: String { @@ -545,6 +527,8 @@ enum CLSMessageType { return "Monthly Completion Limit Reached" case .byokLimitedReached: return "BYOK Limit Reached" + case .monthlyAICreditsLimitReached: + return "Monthly AI Credits Limit Reached" case .other: return "CLS Error" } @@ -556,14 +540,6 @@ struct CLSMessage { let detail: String } -func extractDateFromCLSMessage(_ message: String) -> String? { - let pattern = #"until (\d{1,2}/\d{1,2}/\d{4}, \d{1,2}:\d{2}:\d{2} [AP]M)"# - if let range = message.range(of: pattern, options: .regularExpression) { - return String(message[range].dropFirst(6)) - } - return nil -} - func getCLSMessageSummary(_ message: String) -> CLSMessage { let messageType: CLSMessageType @@ -574,16 +550,11 @@ func getCLSMessageSummary(_ message: String) -> CLSMessage { messageType = .completionLimitReached } else if message.contains("BYOK") { messageType = .byokLimitedReached + } else if message.contains("You've used your monthly AI Credits") { + messageType = .monthlyAICreditsLimitReached } else { messageType = .other } - let detail: String - if let date = extractDateFromCLSMessage(message) { - detail = "Visit GitHub to check your usage and upgrade to Copilot Pro or wait until \(date) for your limit to reset." - } else { - detail = message - } - - return CLSMessage(summary: messageType.summary, detail: detail) + return CLSMessage(summary: messageType.summary, detail: message) } diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/Contents.json b/ExtensionService/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index 0a30d46d..00000000 --- a/ExtensionService/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "images" : [ - { - "filename" : "CopilotforXcode-Icon@16w_1x.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "16x16" - }, - { - "filename" : "CopilotforXcode-Icon@16w_2x.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "16x16" - }, - { - "filename" : "CopilotforXcode-Icon@32w_1x.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "32x32" - }, - { - "filename" : "CopilotforXcode-Icon@32w_2x.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "32x32" - }, - { - "filename" : "CopilotforXcode-Icon@128w_1x.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "128x128" - }, - { - "filename" : "CopilotforXcode-Icon@128w_2x.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "128x128" - }, - { - "filename" : "CopilotforXcode-Icon@256w_1x.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "256x256" - }, - { - "filename" : "CopilotforXcode-Icon@256w_2x.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "256x256" - }, - { - "filename" : "CopilotforXcode-Icon@512w_1x.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "512x512" - }, - { - "filename" : "CopilotforXcode-Icon@512w_2x.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "512x512" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@128w_1x.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@128w_1x.png deleted file mode 100644 index 3ee52427..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@128w_1x.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@128w_2x.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@128w_2x.png deleted file mode 100644 index 88b20d1d..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@128w_2x.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@16w_1x.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@16w_1x.png deleted file mode 100644 index 2bb554dc..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@16w_1x.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@16w_2x.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@16w_2x.png deleted file mode 100644 index ce02bac7..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@16w_2x.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@256w_1x.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@256w_1x.png deleted file mode 100644 index 7674f663..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@256w_1x.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@256w_2x.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@256w_2x.png deleted file mode 100644 index fc705969..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@256w_2x.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@32w_1x.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@32w_1x.png deleted file mode 100644 index ce02bac7..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@32w_1x.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@32w_2x.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@32w_2x.png deleted file mode 100644 index 4d52c81b..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@32w_2x.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@512w_1x.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@512w_1x.png deleted file mode 100644 index fc705969..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@512w_1x.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@512w_2x.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@512w_2x.png deleted file mode 100644 index 54da6e3f..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@512w_2x.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/BannerBackgroundColor.colorset/Contents.json b/ExtensionService/Assets.xcassets/BannerBackgroundColor.colorset/Contents.json new file mode 100644 index 00000000..ee2a2c8e --- /dev/null +++ b/ExtensionService/Assets.xcassets/BannerBackgroundColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0xFE", + "green" : "0xF8", + "red" : "0xF5" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0x4D", + "green" : "0x32", + "red" : "0x25" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/BannerBorderColor.colorset/Contents.json b/ExtensionService/Assets.xcassets/BannerBorderColor.colorset/Contents.json new file mode 100644 index 00000000..fa914237 --- /dev/null +++ b/ExtensionService/Assets.xcassets/BannerBorderColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0xFC", + "green" : "0xD6", + "red" : "0xC2" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0x8F", + "green" : "0x53", + "red" : "0x35" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/CopilotLogo.imageset/Contents.json b/ExtensionService/Assets.xcassets/CopilotLogo.imageset/Contents.json deleted file mode 100644 index 2e35661e..00000000 --- a/ExtensionService/Assets.xcassets/CopilotLogo.imageset/Contents.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "images" : [ - { - "filename" : "copilot.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true - } -} diff --git a/ExtensionService/Assets.xcassets/CopilotLogo.imageset/copilot.svg b/ExtensionService/Assets.xcassets/CopilotLogo.imageset/copilot.svg deleted file mode 100644 index 8284dce7..00000000 --- a/ExtensionService/Assets.xcassets/CopilotLogo.imageset/copilot.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - 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 a0e3fbda..74db8114 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,15 @@ # GitHub Copilot for Xcode -[GitHub Copilot](https://github.com/features/copilot) for Xcode is the leading AI coding assistant for Xcode developers, helping you code faster and smarter. Stay in flow with **inline completions** and get instant help through **chat support**—explaining code, answering questions, and suggesting improvements. When you need more, Copilot scales with advanced features like **Agent Mode, MCP Registry, Copilot Vision, Code Review, Custom Instructions, and more**, making your Xcode workflow more efficient and intelligent. +[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. +> [!IMPORTANT] +> Starting from version v0.50.0, we have added internal support for the upcoming usage-based billing experience, including experience updates to the usage panel, usage notifications, and model picker. These changes will become visible once usage-based billing is rolled out. +> +> To ensure compatibility with the new billing experience, we strongly recommend upgrading to the latest plugin version as soon as possible: +> +> * **GitHub Copilot for Xcode: v0.50.0 or later** +> +> Clients using older plugin versions will continue to function. However, the billing and usage experience may not be optimal and may not accurately reflect the latest usage-based billing experience. ## Chat @@ -27,9 +35,9 @@ You can receive auto-complete type suggestions from GitHub Copilot either by sta ## Requirements -- macOS 12+ -- Xcode 8+ -- A GitHub Copilot subscription. To learn more, visit [https://github.com/features/copilot](https://github.com/features/copilot). +- macOS 13+ +- Xcode 14+ +- A GitHub account ## Getting Started diff --git a/ReleaseNotes.md b/ReleaseNotes.md index e80bbc2b..3c922d4a 100644 --- a/ReleaseNotes.md +++ b/ReleaseNotes.md @@ -1,19 +1,13 @@ -### GitHub Copilot for Xcode 0.46.0 +### GitHub Copilot for Xcode 0.50.0 -**🎄 Holiday Special** +**🚀 Highlights** -- As the holiday season approaches, we want to wish all our users a Merry Christmas and a Happy New Year! 🎅✨ This release focuses on refinements and bug fixes to ensure a smooth coding experience during the holidays. Stay tuned for more exciting features in the next release! +- **Reasoning Effort**: Control how deeply reasoning-capable models think before responding. You can now select reasoning efforts directly from the model picker for supported models, letting you balance response speed against answer quality. +- **Bring Your Own Key (BYOK) is now Generally Available**: Bring Your Own Key support has graduated from preview and is now available to all users. Configure your own API keys for third-party models directly in Copilot for Xcode settings. -**💪 Improvements** +**💪 Changes** -- **Refined Tool Layout**: Enhanced the display of tool calls, including better error reporting and output details. -- **MCP Improvements**: Added support for deleting MCP servers directly from the list. -- **Feedback**: Updated the feedback forum link to better hear from you. +- Added internal support for upcoming [usage-based billing](https://github.blog/news-insights/company-news/github-copilot-is-moving-to-usage-based-billing/), including billing updates for the usage panel, usage notifications, and model picker. This will be visible once usage-based billing rolls out. -**🛠️ Bug Fixes** - -- Fixed an issue where the "Fix Error" window would auto-focus unexpectedly. -- Resolved issues with `insert_edit_into_file` where changes were sometimes not applied or applied to the incorrect file. -- Fixed a display bug in the model picker for models with same name. -- Fixed random failures in `read_file` and `read_directory` tools. + We **strongly recommend** upgrading to this version as soon as possible. diff --git a/Server/package-lock.json b/Server/package-lock.json index 238ed6f4..27f0877c 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.403.0", - "@github/copilot-language-server-darwin-arm64": "1.403.0", - "@github/copilot-language-server-darwin-x64": "1.403.0", + "@github/copilot-language-server": "1.488.0", + "@github/copilot-language-server-darwin-arm64": "1.488.0", + "@github/copilot-language-server-darwin-x64": "1.488.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.403.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.403.0.tgz", - "integrity": "sha512-ciPxnERqbbN9MRn2Ghaje17UH8cotLA7s9Lypqz9voStagBKUg5Nbiv5yjiGXm6j8e1OiE/BY0zhfLv3xFdOcw==", + "version": "1.488.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.488.0.tgz", + "integrity": "sha512-zCyItvYtqrtQzpdAv6nTjph4Nfws5xTMNAw7cn2gIvBEBHT5NbnaceSwxJm2I96Ll2Jrq4uQG+wifYVMHjQDwg==", "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.403.0", - "@github/copilot-language-server-darwin-x64": "1.403.0", - "@github/copilot-language-server-linux-arm64": "1.403.0", - "@github/copilot-language-server-linux-x64": "1.403.0", - "@github/copilot-language-server-win32-x64": "1.403.0" + "@github/copilot-language-server-darwin-arm64": "1.488.0", + "@github/copilot-language-server-darwin-x64": "1.488.0", + "@github/copilot-language-server-linux-arm64": "1.488.0", + "@github/copilot-language-server-linux-x64": "1.488.0", + "@github/copilot-language-server-win32-arm64": "1.488.0", + "@github/copilot-language-server-win32-x64": "1.488.0" } }, "node_modules/@github/copilot-language-server-darwin-arm64": { - "version": "1.403.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-arm64/-/copilot-language-server-darwin-arm64-1.403.0.tgz", - "integrity": "sha512-LI1TkqehsWv38OEv8T0iI438X9nQJYDodlJ+WDyCwImw4m3wdIabe0qQvFUv68dPOtxU5kDv5by8Lm5Pjmigdg==", + "version": "1.488.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-arm64/-/copilot-language-server-darwin-arm64-1.488.0.tgz", + "integrity": "sha512-X4jqAKJwNJIM2Ua22UZvPM6/3x7ZiX4lgedvqWadoFA9SJmxF6OlQoe2L6coeC1U8RWj5WEyTqIDkyJkhsF24Q==", "cpu": [ "arm64" ], @@ -69,9 +70,9 @@ ] }, "node_modules/@github/copilot-language-server-darwin-x64": { - "version": "1.403.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-x64/-/copilot-language-server-darwin-x64-1.403.0.tgz", - "integrity": "sha512-DP9D/IW1ldzRLVQRj99PemABNRkWvXMyYviwdKx083eZkl1qWkkDNO2NpyUSGjCpfBTruoGRey3/+Q/E0Ul9RA==", + "version": "1.488.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-x64/-/copilot-language-server-darwin-x64-1.488.0.tgz", + "integrity": "sha512-JHmdBwxwfZfxWkJLNp4M2bydrkD/TsxPoPeojcIOscrHhTj2riWSe7zqUn61IAdtwG0z2HwK1tGS35KOJhW8+A==", "cpu": [ "x64" ], @@ -81,9 +82,9 @@ ] }, "node_modules/@github/copilot-language-server-linux-arm64": { - "version": "1.403.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-arm64/-/copilot-language-server-linux-arm64-1.403.0.tgz", - "integrity": "sha512-dQQzqemUbO7lKTtQszfKPLkedwvLl2VdBycmCIGE4vTNi6K+DD4c3Lx94xYMGnKvarzEvCNk/FtBfixlAUxleQ==", + "version": "1.488.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-arm64/-/copilot-language-server-linux-arm64-1.488.0.tgz", + "integrity": "sha512-t+MZuQl76nMAs0qzAZDb9hr3SC53cW87CmDI+lgfLfXay8cdunNirjI/IloRnXybYYKFCkjvqDl9cYcLdCA1/g==", "cpu": [ "arm64" ], @@ -94,9 +95,9 @@ ] }, "node_modules/@github/copilot-language-server-linux-x64": { - "version": "1.403.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-x64/-/copilot-language-server-linux-x64-1.403.0.tgz", - "integrity": "sha512-INJyxAKSCFxeGae/OnpsdxIgbvtCN/aGM8UpVnEBjwWeQOhbPvZYLnvu0Xvqt0cs2EYJ8hhosti6oWqU6m/HRg==", + "version": "1.488.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-x64/-/copilot-language-server-linux-x64-1.488.0.tgz", + "integrity": "sha512-hhJ2ZVweqLCk0lptMXyHlXPrkSiHypcpza5gVUwMGMuw7Z87o3SkK8fD5jDpBvKJ0gJTGjPZ2yXzGtErpY20Sw==", "cpu": [ "x64" ], @@ -106,10 +107,23 @@ "linux" ] }, + "node_modules/@github/copilot-language-server-win32-arm64": { + "version": "1.488.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-win32-arm64/-/copilot-language-server-win32-arm64-1.488.0.tgz", + "integrity": "sha512-ke8czQwJcOtZpD9CnyBE7nVrFU9oa8nrLMp8xWoxKybs77Rpt36v2CGw535QHrih9f3Hp4dQtFlaRdvjnL1vZg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@github/copilot-language-server-win32-x64": { - "version": "1.403.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-win32-x64/-/copilot-language-server-win32-x64-1.403.0.tgz", - "integrity": "sha512-mXmLFDNYybejDC9XcPHpIxr8BCNaXs5U0danfIXHuOQ497c7hycQvxtsZcka1VsFhE0IB7BVsoD9N3ieFCAMuA==", + "version": "1.488.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-win32-x64/-/copilot-language-server-win32-x64-1.488.0.tgz", + "integrity": "sha512-TOxA86DcpO7VYfsePj5PmbzqKaTwGXse6OBdMAPKPBBBCiusEI1kmYO3O/ywi8n5cCF1X9LvrqbunNRzokxRug==", "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", @@ -910,9 +924,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", - "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "dev": true, "funding": [ { @@ -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 b862ef9d..4c6bdba0 100644 --- a/Server/package.json +++ b/Server/package.json @@ -7,19 +7,19 @@ "build": "webpack" }, "dependencies": { - "@github/copilot-language-server": "1.403.0", - "@github/copilot-language-server-darwin-arm64": "1.403.0", - "@github/copilot-language-server-darwin-x64": "1.403.0", + "@github/copilot-language-server": "1.488.0", + "@github/copilot-language-server-darwin-arm64": "1.488.0", + "@github/copilot-language-server-darwin-x64": "1.488.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "monaco-editor": "0.52.2" }, "devDependencies": { "@types/node": "^22.15.17", - "copy-webpack-plugin": "^13.0.1", + "copy-webpack-plugin": "^14.0.0", "css-loader": "^7.1.2", "style-loader": "^4.0.0", - "terser-webpack-plugin": "^5.3.14", + "terser-webpack-plugin": "^5.4.0", "ts-loader": "^9.5.4", "typescript": "^5.8.3", "webpack": "^5.99.9", diff --git a/Tool/Package.swift b/Tool/Package.swift index b54bd789..2b3c4b14 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"]), @@ -66,7 +66,8 @@ let package = Package( .library(name: "StatusBarItemView", targets: ["StatusBarItemView"]), .library(name: "HostAppActivator", targets: ["HostAppActivator"]), .library(name: "AppKitExtension", targets: ["AppKitExtension"]), - .library(name: "GitHelper", targets: ["GitHelper"]) + .library(name: "GitHelper", targets: ["GitHelper"]), + .library(name: "NotificationCenterCoordinator", targets: ["NotificationCenterCoordinator"]) ], dependencies: [ // TODO: Update LanguageClient some day. @@ -189,6 +190,7 @@ let package = Package( dependencies: [ "SuggestionBasic", "SuggestionProvider", + "TelemetryServiceProvider", "Workspace", .product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"), ] @@ -201,6 +203,7 @@ let package = Package( "Highlightr", "Preferences", "SuggestionBasic", + "Status", "DebounceFunction", "ConversationServiceProvider", .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), @@ -240,7 +243,7 @@ let package = Package( ] ), - .target(name: "StatusBarItemView", dependencies: ["Cache"]), + .target(name: "StatusBarItemView", dependencies: ["Cache", "Status"]), .target( name: "Cache" @@ -266,7 +269,8 @@ let package = Package( dependencies: [ "Logger", "Status", - .product(name: "SQLite", package: "SQLite.Swift") + .product(name: "SQLite", package: "SQLite.Swift"), + .product(name: "JSONRPC", package: "JSONRPC") ] ), @@ -304,6 +308,8 @@ let package = Package( // MARK: - GitHub Copilot + .target(name: "NotificationCenterCoordinator"), + .target( name: "GitHubCopilotService", dependencies: [ @@ -320,6 +326,7 @@ let package = Package( "Workspace", "Persist", "SuggestionProvider", + "NotificationCenterCoordinator", .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 index d9b1da9c..ff5948cc 100644 --- a/Tool/Sources/AXExtension/AXUIElement+Xcode.swift +++ b/Tool/Sources/AXExtension/AXUIElement+Xcode.swift @@ -28,4 +28,16 @@ public extension AXUIElement { 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 e9b9ed3b..6d0a2584 100644 --- a/Tool/Sources/AXExtension/AXUIElement.swift +++ b/Tool/Sources/AXExtension/AXUIElement.swift @@ -235,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) } } @@ -327,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 } } @@ -354,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 } } @@ -373,6 +373,8 @@ public extension AXUIElement { } #if hasFeature(RetroactiveAttribute) +extension AXError: @retroactive _BridgedNSError {} +extension AXError: @retroactive _ObjectiveCBridgeableError {} extension AXError: @retroactive Error {} #else extension AXError: Error {} diff --git a/Tool/Sources/AXHelper/AXHelper.swift b/Tool/Sources/AXHelper/AXHelper.swift index 7f44ef1a..533a3c1e 100644 --- a/Tool/Sources/AXHelper/AXHelper.swift +++ b/Tool/Sources/AXHelper/AXHelper.swift @@ -100,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/BuiltinExtensionConversationServiceProvider.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift index 14625052..fbf5852b 100644 --- a/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift +++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift @@ -211,7 +211,19 @@ public final class BuiltinExtensionConversationServiceProvider< Logger.service.error("Could not get active workspace info") return nil } - + return (try? await conversationService.reviewChanges(workspace: workspaceInfo, changes: changes)) } + + public func generateThinkingTitle(_ params: GenerateThinkingTitleParams) async throws -> GenerateThinkingTitleResponse? { + 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.generateThinkingTitle(workspace: workspaceInfo, params: params) + } } diff --git a/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift b/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift index d988a91e..5fd4bc26 100644 --- a/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift +++ b/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift @@ -22,14 +22,14 @@ public extension ChatMemory { 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 - + mergeThinking(into: &existingSubRounds[subIndex].thinking, from: messageRound.thinking) if let messageToolCalls = messageRound.toolCalls, !messageToolCalls.isEmpty { var mergedToolCalls = existingSubRounds[subIndex].toolCalls ?? [] for newToolCall in messageToolCalls { @@ -77,7 +77,7 @@ public extension ChatMemory { parentMessage.editAgentRounds = parentRounds } } - + history[parentIndex] = parentMessage } else if let index = history.firstIndex(where: { $0.id == message.id }) { history[index].mergeMessage(with: message) @@ -137,15 +137,17 @@ extension ChatMessage { self.steps = mergedSteps } - + if !message.editAgentRounds.isEmpty { let mergedAgentRounds = mergeEditAgentRounds( - oldRounds: self.editAgentRounds, + oldRounds: self.editAgentRounds, newRounds: message.editAgentRounds ) - + self.editAgentRounds = mergedAgentRounds } + + mergeThinking(into: &self.thinking, from: message.thinking) self.parentTurnId = message.parentTurnId ?? self.parentTurnId @@ -157,7 +159,9 @@ extension ChatMessage { // merge modelName and billingMultiplier self.modelName = message.modelName ?? self.modelName + self.modelProviderName = message.modelProviderName ?? self.modelProviderName self.billingMultiplier = message.billingMultiplier ?? self.billingMultiplier + self.reasoningEffort = message.reasoningEffort ?? self.reasoningEffort } private func mergeEditAgentRounds(oldRounds: [AgentRound], newRounds: [AgentRound]) -> [AgentRound] { @@ -166,7 +170,9 @@ extension ChatMessage { for newRound in newRounds { if let index = mergedAgentRounds.firstIndex(where: { $0.roundId == newRound.roundId }) { mergedAgentRounds[index].reply = mergedAgentRounds[index].reply + newRound.reply - + + mergeThinking(into: &mergedAgentRounds[index].thinking, from: newRound.thinking) + if newRound.toolCalls != nil, !newRound.toolCalls!.isEmpty { var mergedToolCalls = mergedAgentRounds[index].toolCalls ?? [] for newToolCall in newRound.toolCalls! { @@ -266,3 +272,34 @@ extension ChatMessage { return edits } } + +/// Merges incoming thinking deltas into an accumulated thinking array. Deltas are matched by +/// `clientEntryId` (a stable client-generated key), so server delta `id` churn does not split a + /// streaming block. New entries (different `clientEntryId`) append; for the same entry, text + /// concatenates, `id` is replaced with the latest server value, `encrypted` and `title` keep + /// their existing values when the incoming delta omits them, and `isComplete` remains `true` + /// once any delta marks it complete. +internal func mergeThinking(into accumulator: inout [MessageThinking], from incoming: [MessageThinking]) { + for newThinking in incoming { + let hasNewText = !(newThinking.text?.allSatisfy { $0.isEmpty } ?? true) + let hasNewTitle = newThinking.title != nil + + if let index = accumulator.firstIndex(where: { $0.clientEntryId == newThinking.clientEntryId }) { + let existing = accumulator[index] + var mergedText = existing.text ?? [] + if let new = newThinking.text { + mergedText.append(contentsOf: new) + } + accumulator[index] = MessageThinking( + clientEntryId: existing.clientEntryId, + id: newThinking.id, + text: mergedText.isEmpty ? nil : mergedText, + encrypted: newThinking.encrypted ?? existing.encrypted, + title: newThinking.title ?? existing.title, + isComplete: newThinking.isComplete || existing.isComplete + ) + } else if hasNewText || hasNewTitle { + accumulator.append(newThinking) + } + } +} diff --git a/Tool/Sources/ChatAPIService/Models.swift b/Tool/Sources/ChatAPIService/Models.swift index 6d205f5e..868a13b3 100644 --- a/Tool/Sources/ChatAPIService/Models.swift +++ b/Tool/Sources/ChatAPIService/Models.swift @@ -107,6 +107,7 @@ public enum RequestType: String, Equatable, Codable { } 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 @@ -157,7 +158,9 @@ public struct ChatMessage: Equatable, Codable { /// The steps of conversation progress public var steps: [ConversationProgressStep] - + + public var thinking: [MessageThinking] + public var editAgentRounds: [AgentRound] public var parentTurnId: String? @@ -177,7 +180,9 @@ public struct ChatMessage: Equatable, Codable { // The model name used for the turn. public var modelName: String? + public var modelProviderName: String? public var billingMultiplier: Float? + public var reasoningEffort: String? /// The timestamp of the message. public var createdAt: Date @@ -197,6 +202,7 @@ public struct ChatMessage: Equatable, Codable { rating: ConversationRating = .unrated, steps: [ConversationProgressStep] = [], editAgentRounds: [AgentRound] = [], + thinking: [MessageThinking] = [], parentTurnId: String? = nil, panelMessages: [CopilotShowMessageParams] = [], codeReviewRound: CodeReviewRound? = nil, @@ -204,7 +210,9 @@ public struct ChatMessage: Equatable, Codable { turnStatus: TurnStatus? = nil, requestType: RequestType = .conversation, modelName: String? = nil, + modelProviderName: String? = nil, billingMultiplier: Float? = nil, + reasoningEffort: String? = nil, createdAt: Date? = nil, updatedAt: Date? = nil ) { @@ -220,6 +228,7 @@ public struct ChatMessage: Equatable, Codable { self.errorMessages = errorMessages self.rating = rating self.steps = steps + self.thinking = thinking self.editAgentRounds = editAgentRounds self.parentTurnId = parentTurnId self.panelMessages = panelMessages @@ -228,7 +237,9 @@ public struct ChatMessage: Equatable, Codable { self.turnStatus = turnStatus self.requestType = requestType self.modelName = modelName + self.modelProviderName = modelProviderName self.billingMultiplier = billingMultiplier + self.reasoningEffort = reasoningEffort let now = Date.now self.createdAt = createdAt ?? now @@ -263,13 +274,16 @@ public struct ChatMessage: Equatable, Codable { suggestedTitle: String? = nil, steps: [ConversationProgressStep] = [], editAgentRounds: [AgentRound] = [], + thinking: [MessageThinking] = [], parentTurnId: String? = nil, codeReviewRound: CodeReviewRound? = nil, fileEdits: [FileEdit] = [], turnStatus: TurnStatus? = nil, requestType: RequestType = .conversation, modelName: String? = nil, - billingMultiplier: Float? = nil + modelProviderName: String? = nil, + billingMultiplier: Float? = nil, + reasoningEffort: String? = nil ) { self.init( id: id, @@ -282,13 +296,16 @@ public struct ChatMessage: Equatable, Codable { suggestedTitle: suggestedTitle, steps: steps, editAgentRounds: editAgentRounds, + thinking: thinking, parentTurnId: parentTurnId, codeReviewRound: codeReviewRound, fileEdits: fileEdits, turnStatus: turnStatus, requestType: requestType, modelName: modelName, - billingMultiplier: billingMultiplier + modelProviderName: modelProviderName, + billingMultiplier: billingMultiplier, + reasoningEffort: reasoningEffort ) } diff --git a/Tool/Sources/ChatTab/ChatTab.swift b/Tool/Sources/ChatTab/ChatTab.swift index 0612cca5..bcaeac23 100644 --- a/Tool/Sources/ChatTab/ChatTab.swift +++ b/Tool/Sources/ChatTab/ChatTab.swift @@ -19,7 +19,7 @@ public struct ChatTabPreviewInfo: Identifiable, Equatable, Codable { /// The information of a tab. @ObservableState -public struct ChatTabInfo: Identifiable, Equatable, Codable { +public struct ChatTabInfo: Identifiable, Equatable, Hashable, Codable { public var id: String public var title: String? = nil public var isTitleSet: Bool { 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/ConversationServiceProvider.swift b/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift index d16369a7..aaa13917 100644 --- a/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift +++ b/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift @@ -20,6 +20,7 @@ public protocol ConversationServiceType { workspace: WorkspaceInfo, changes: [ReviewChangesParams.Change] ) async throws -> CodeReviewResult? + func generateThinkingTitle(workspace: WorkspaceInfo, params: GenerateThinkingTitleParams) async throws -> GenerateThinkingTitleResponse? } public protocol ConversationServiceProvider { @@ -36,6 +37,7 @@ public protocol ConversationServiceProvider { func agents() async throws -> [ChatAgent]? func notifyChangeTextDocument(fileURL: URL, content: String, version: Int, workspaceURL: URL?) async throws func reviewChanges(_ changes: [ReviewChangesParams.Change]) async throws -> CodeReviewResult? + func generateThinkingTitle(_ params: GenerateThinkingTitleParams) async throws -> GenerateThinkingTitleResponse? } public struct ConversationFileReference: Hashable, Codable, Equatable { @@ -344,6 +346,7 @@ public struct ConversationRequest { public var references: [ConversationAttachedReference]? public var model: String? public var modelProviderName: String? + public var reasoningEffort: String? public var turns: [TurnSchema] public var agentMode: Bool = false public var customChatModeId: String? = nil @@ -361,6 +364,7 @@ public struct ConversationRequest { references: [ConversationAttachedReference]? = nil, model: String? = nil, modelProviderName: String? = nil, + reasoningEffort: String? = nil, turns: [TurnSchema] = [], agentMode: Bool = false, customChatModeId: String? = nil, @@ -377,6 +381,7 @@ public struct ConversationRequest { self.references = references self.model = model self.modelProviderName = modelProviderName + self.reasoningEffort = reasoningEffort self.turns = turns self.agentMode = agentMode self.customChatModeId = customChatModeId @@ -451,6 +456,146 @@ public struct ConversationProgressStep: Codable, Equatable, Identifiable { } } +public struct Thinking: Codable, Equatable { + public let id: String + public let text: [String]? + public let encrypted: String? + + public init(id: String, text: [String]?, encrypted: String?) { + self.id = id + self.text = text + self.encrypted = encrypted + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + encrypted = try container.decodeIfPresent(String.self, forKey: .encrypted) + text = try container.decodeStringOrArray(forKey: .text) + } +} + +/// Internal, message-level thinking state. +/// +/// Distinct from the wire/server `Thinking` payload above: that type carries deltas +/// streamed from the LSP, while `MessageThinking` is the accumulated UI state stored on +/// a `ChatMessage` (or `AgentRound`) and persisted across sessions. `isComplete` is a +/// UI/state flag the server never sends — it's set when a thinking block ends. +public struct MessageThinking: Codable, Equatable { + /// Stable client-generated key for this entry. Survives server delta `id` churn (e.g. + /// CodeX models emit a new `id` per delta) and is what the seal/title-attach code paths + /// look up. Persisted; older saved messages without it get a fresh UUID on decode. + public var clientEntryId: UUID + public var id: String + public var text: [String]? + public var encrypted: String? + public var title: String? + public var isComplete: Bool + + public init( + clientEntryId: UUID = UUID(), + id: String, + text: [String]?, + encrypted: String?, + title: String? = nil, + isComplete: Bool = false + ) { + self.clientEntryId = clientEntryId + self.id = id + self.text = text + self.encrypted = encrypted + self.title = title + self.isComplete = isComplete + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + clientEntryId = try container.decodeIfPresent(UUID.self, forKey: .clientEntryId) ?? UUID() + id = try container.decode(String.self, forKey: .id) + encrypted = try container.decodeIfPresent(String.self, forKey: .encrypted) + title = try container.decodeIfPresent(String.self, forKey: .title) + isComplete = try container.decodeIfPresent(Bool.self, forKey: .isComplete) ?? false + text = try container.decodeStringOrArray(forKey: .text) + } + + public init(from server: Thinking, clientEntryId: UUID = UUID(), isComplete: Bool = false) { + self.clientEntryId = clientEntryId + self.id = server.id + self.text = server.text + self.encrypted = server.encrypted + self.title = nil + self.isComplete = isComplete + } + + /// Parses thinking text into title-paired sections. + /// + /// Each "title-only" line (`**Title**` on its own) starts a new section. All lines that + /// follow up to the next title (or end of text) become that section's body. Lines before + /// any title go into a leading section with `title == nil`. + public static func parseSections(from raw: String) -> [ThinkingSection] { + if raw.isEmpty { return [] } + var sections: [ThinkingSection] = [] + var currentTitle: String? = nil + var currentBody: [String] = [] + + func flush() { + let body = currentBody.joined().trimmingCharacters(in: .whitespacesAndNewlines) + if currentTitle != nil || !body.isEmpty { + sections.append(ThinkingSection(title: currentTitle, body: body)) + } + } + + for line in raw.components(separatedBy: "\n") { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("**"), trimmed.hasSuffix("**"), trimmed.count > 4 { + let inner = String(trimmed.dropFirst(2).dropLast(2)) + if !inner.isEmpty, !inner.contains("*") { + flush() + currentTitle = inner + currentBody = [] + continue + } + } + currentBody.append(line + "\n") + } + flush() + return sections + } +} + +public struct ThinkingSection: Equatable { + public let title: String? + public let body: String + + public init(title: String?, body: String) { + self.title = title + self.body = body + } +} + +public extension KeyedDecodingContainer { + /// Decodes a value that the wire format may emit as either a single `String` or `[String]`, + /// normalizing to `[String]?`. Returns `nil` if the key is absent. + func decodeStringOrArray(forKey key: Key) throws -> [String]? { + if let single = try? decode(String.self, forKey: key) { + return [single] + } + return try decodeIfPresent([String].self, forKey: key) + } +} + +public struct ContextSizeInfo: Codable, Equatable { + public let totalTokenLimit: Int + public let systemPromptTokens: Int + public let toolDefinitionTokens: Int + public let userMessagesTokens: Int + public let assistantMessagesTokens: Int + public let attachedFilesTokens: Int + public let toolResultsTokens: Int + public let totalUsedTokens: Int + public let utilizationPercentage: Double +} + public struct DidChangeWatchedFilesEvent: Codable { public var workspaceUri: String public var changes: [FileEvent] diff --git a/Tool/Sources/ConversationServiceProvider/LSPTypes+AgentRound.swift b/Tool/Sources/ConversationServiceProvider/LSPTypes+AgentRound.swift index 69124626..95579d41 100644 --- a/Tool/Sources/ConversationServiceProvider/LSPTypes+AgentRound.swift +++ b/Tool/Sources/ConversationServiceProvider/LSPTypes+AgentRound.swift @@ -7,12 +7,29 @@ public struct AgentRound: Codable, Equatable { public var reply: String public var toolCalls: [AgentToolCall]? public var subAgentRounds: [AgentRound]? - - public init(roundId: Int, reply: String, toolCalls: [AgentToolCall]? = [], subAgentRounds: [AgentRound]? = []) { + public var thinking: [MessageThinking] + + public init(roundId: Int, reply: String, toolCalls: [AgentToolCall]? = [], subAgentRounds: [AgentRound]? = [], thinking: [MessageThinking] = []) { self.roundId = roundId self.reply = reply self.toolCalls = toolCalls self.subAgentRounds = subAgentRounds + self.thinking = thinking + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + roundId = try container.decode(Int.self, forKey: .roundId) + reply = try container.decode(String.self, forKey: .reply) + toolCalls = try container.decodeIfPresent([AgentToolCall].self, forKey: .toolCalls) + subAgentRounds = try container.decodeIfPresent([AgentRound].self, forKey: .subAgentRounds) + if let array = try? container.decodeIfPresent([MessageThinking].self, forKey: .thinking) { + thinking = array + } else if let single = try? container.decodeIfPresent(MessageThinking.self, forKey: .thinking) { + thinking = [single] + } else { + thinking = [] + } } } diff --git a/Tool/Sources/ConversationServiceProvider/LSPTypes.swift b/Tool/Sources/ConversationServiceProvider/LSPTypes.swift index 289fcdbd..88b86933 100644 --- a/Tool/Sources/ConversationServiceProvider/LSPTypes.swift +++ b/Tool/Sources/ConversationServiceProvider/LSPTypes.swift @@ -24,6 +24,7 @@ public enum PromptTemplateScope: String, Codable, Equatable { case agentPanel = "agent-panel" case editor = "editor" case inline = "inline" + case inlineAgent = "inline-agent" case completion = "completion" } @@ -39,6 +40,7 @@ public struct CopilotModel: Codable, Equatable { public let modelFamily: String public let modelName: String public let id: String + public let vendor: String? public let modelPolicy: CopilotModelPolicy? public let scopes: [PromptTemplateScope] public let preview: Bool @@ -46,6 +48,9 @@ public struct CopilotModel: Codable, Equatable { public let isChatFallback: Bool public let capabilities: CopilotModelCapabilities public let billing: CopilotModelBilling? + public let degradationReason: String? + public let modelPickerCategory: String? + public let modelPickerPriceCategory: String? } public struct CopilotModelPolicy: Codable, Equatable { @@ -55,27 +60,49 @@ public struct CopilotModelPolicy: Codable, Equatable { public struct CopilotModelCapabilities: Codable, Equatable { public let supports: CopilotModelCapabilitiesSupports + public let limits: CopilotModelCapabilitiesLimits? +} + +public struct CopilotModelCapabilitiesLimits: Codable, Equatable { + public let maxContextWindowTokens: Int? + public let maxOutputTokens: Int? + public let maxInputTokens: Int? + public let maxNonStreamingOutputTokens: Int? } public struct CopilotModelCapabilitiesSupports: Codable, Equatable { public let vision: Bool + public let reasoningEfforts: [String]? + public let supportsReasoningEffortLevel: Bool? } public struct CopilotModelBilling: Codable, Equatable, Hashable { public let isPremium: Bool public let multiplier: Float - - public init(isPremium: Bool, multiplier: Float) { + public let tokenBasedBillingEnabled: Bool? + public let tokenPrices: CopilotModelBillingTokenPrices? + + public init(isPremium: Bool, multiplier: Float, tokenBasedBillingEnabled: Bool? = nil, tokenPrices: CopilotModelBillingTokenPrices? = nil) { self.isPremium = isPremium self.multiplier = multiplier + self.tokenBasedBillingEnabled = tokenBasedBillingEnabled + self.tokenPrices = tokenPrices } } +public struct CopilotModelBillingTokenPrices: Codable, Equatable, Hashable { + public let cachePrice: Float? + public let inputPrice: Float? + public let outputPrice: Float? + public let tokenUnit: Int? +} + // MARK: ChatModes public enum ChatMode: String, Codable { case Ask = "Ask" case Edit = "Edit" case Agent = "Agent" + case InlineAgent = "InlineAgent" } public struct ConversationMode: Codable, Equatable { @@ -512,6 +539,20 @@ public struct ActionCommand: Codable, Equatable, Hashable { // MARK: - Copilot Code Review +public struct GenerateThinkingTitleParams: Codable { + public var thinkingContent: String? + public var extractedTitles: [String]? + + public init(thinkingContent: String? = nil, extractedTitles: [String]? = nil) { + self.thinkingContent = thinkingContent + self.extractedTitles = extractedTitles + } +} + +public struct GenerateThinkingTitleResponse: Codable { + public var title: String +} + public struct ReviewChangesParams: Codable, Equatable { public struct Change: Codable, Equatable { public let uri: DocumentUri @@ -539,8 +580,8 @@ public struct ReviewChangesParams: Codable, Equatable { } public struct ReviewComment: Codable, Equatable, Hashable { - // Self-defined `id` for using in comment operation. Add an init value to bypass decoding - public let id: String = UUID().uuidString + // Self-defined `id` for using in comment operation. Generated when missing from payload. + public let id: String public let uri: DocumentUri public let range: LSPRange public let message: String @@ -549,7 +590,7 @@ public struct ReviewComment: Codable, Equatable, Hashable { // enum: low, medium, high public let severity: String public let suggestion: String? - + public init( uri: DocumentUri, range: LSPRange, @@ -558,6 +599,7 @@ public struct ReviewComment: Codable, Equatable, Hashable { severity: String, suggestion: String? ) { + self.id = UUID().uuidString self.uri = uri self.range = range self.message = message @@ -565,6 +607,21 @@ public struct ReviewComment: Codable, Equatable, Hashable { self.severity = severity self.suggestion = suggestion } + + private enum CodingKeys: String, CodingKey { + case id, uri, range, message, kind, severity, suggestion + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decodeIfPresent(String.self, forKey: .id) ?? UUID().uuidString + self.uri = try container.decode(DocumentUri.self, forKey: .uri) + self.range = try container.decode(LSPRange.self, forKey: .range) + self.message = try container.decode(String.self, forKey: .message) + self.kind = try container.decode(String.self, forKey: .kind) + self.severity = try container.decode(String.self, forKey: .severity) + self.suggestion = try container.decodeIfPresent(String.self, forKey: .suggestion) + } } public struct CodeReviewResult: Codable, Equatable { @@ -654,6 +711,18 @@ public enum Reference: Codable, Equatable, Hashable { } } +public struct ConversationModelInfo: Codable { + public let id: String? + public let providerName: String? + public let reasoningEffort: String? + + public init(id: String?, providerName: String?, reasoningEffort: String?) { + self.id = id + self.providerName = providerName + self.reasoningEffort = reasoningEffort + } +} + public struct ConversationCreateResponse: Codable { public let conversationId: String public let turnId: String @@ -661,6 +730,7 @@ public struct ConversationCreateResponse: Codable { public let modelName: String? public let modelProviderName: String? public let billingMultiplier: Float? + public let modelInfo: ConversationModelInfo? } public struct ConversationCreateParams: Codable { @@ -676,11 +746,12 @@ public struct ConversationCreateParams: Codable { public var ignoredSkills: [String]? public var model: String? public var modelProviderName: String? + public var modelInfo: ConversationModelInfo? 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? @@ -704,6 +775,7 @@ public struct ConversationCreateParams: Codable { ignoredSkills: [String]? = nil, model: String? = nil, modelProviderName: String? = nil, + modelInfo: ConversationModelInfo? = nil, chatMode: String? = nil, customChatModeId: String? = nil, needToolCallConfirmation: Bool? = nil, @@ -721,6 +793,7 @@ public struct ConversationCreateParams: Codable { self.ignoredSkills = ignoredSkills self.model = model self.modelProviderName = modelProviderName + self.modelInfo = modelInfo self.chatMode = chatMode self.customChatModeId = customChatModeId self.needToolCallConfirmation = needToolCallConfirmation 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/ShowMessageRequestHandler.swift b/Tool/Sources/GitHubCopilotService/Conversation/ShowMessageRequestHandler.swift index ad2de6a7..05e811bf 100644 --- a/Tool/Sources/GitHubCopilotService/Conversation/ShowMessageRequestHandler.swift +++ b/Tool/Sources/GitHubCopilotService/Conversation/ShowMessageRequestHandler.swift @@ -4,6 +4,7 @@ import Combine import Logger import AppKit import LanguageServerProtocol +import NotificationCenterCoordinator import UserNotifications public protocol ShowMessageRequestHandler { @@ -13,28 +14,10 @@ public protocol ShowMessageRequestHandler { ) } -public final class ShowMessageRequestHandlerImpl: NSObject, ShowMessageRequestHandler, UNUserNotificationCenterDelegate { +public final class ShowMessageRequestHandlerImpl: ShowMessageRequestHandler { public static let shared = ShowMessageRequestHandlerImpl() - - private var isNotificationSetup = false - - private override init() { - super.init() - } - - @MainActor - private func setupNotificationCenterIfNeeded() async { - guard !isNotificationSetup else { return } - guard Bundle.main.bundleIdentifier != nil else { - // Skip notification setup in test environment - return - } - - isNotificationSetup = true - UNUserNotificationCenter.current().delegate = self - _ = try? await UNUserNotificationCenter.current() - .requestAuthorization(options: [.alert, .sound]) - } + + private init() {} public func handleShowMessageRequest( _ request: ShowMessageRequest, @@ -43,8 +26,8 @@ public final class ShowMessageRequestHandlerImpl: NSObject, ShowMessageRequestHa guard let params = request.params else { return } Logger.gitHubCopilot.debug("Received Show Message Request: \(params)") Task { @MainActor in - await setupNotificationCenterIfNeeded() - + await NotificationCenterCoordinator.shared.setupIfNeeded() + let actionCount = params.actions?.count ?? 0 // Use notification for messages with no action, alert for messages with actions @@ -103,16 +86,4 @@ public final class ShowMessageRequestHandlerImpl: NSObject, ShowMessageRequestHa return actions[buttonIndex] } - - // MARK: - UNUserNotificationCenterDelegate - - // This method is called when a notification is delivered while the app is in the foreground - public func userNotificationCenter( - _ center: UNUserNotificationCenter, - willPresent notification: UNNotification, - withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void - ) { - // Show the notification banner even when app is in foreground - completionHandler([.banner, .list, .badge, .sound]) - } } diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/BYOKModelManager.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/BYOKModelManager.swift index 1168d954..ba260e60 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/BYOKModelManager.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/BYOKModelManager.swift @@ -8,7 +8,9 @@ public class BYOKModelManager { let sortedModels = BYOKModels.sorted() guard sortedModels != availableBYOKModels else { return } availableBYOKModels = sortedModels - NotificationCenter.default.post(name: .gitHubCopilotModelsDidChange, object: nil) + DispatchQueue.main.async { + NotificationCenter.default.post(name: .gitHubCopilotModelsDidChange, object: nil) + } } public static func hasBYOKModels(providerName: BYOKProviderName? = nil) -> Bool { diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift index 7b380443..b28139ca 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift @@ -225,6 +225,21 @@ class CopilotLocalProcessServer { case "policy/didChange": notificationPublisher.send(anyNotification) return true + case "$/copilot/compressionStarted": + notificationPublisher.send(anyNotification) + return true + case "$/copilot/compressionCompleted": + notificationPublisher.send(anyNotification) + return true + case "$/copilot/rateLimitWarning": + notificationPublisher.send(anyNotification) + return true + case "copilot/quotaChange": + notificationPublisher.send(anyNotification) + return true + case "copilot/quotaWarning": + notificationPublisher.send(anyNotification) + return true case "conversation/preconditionsNotification", "statusNotification": // Ignore return true diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift index 875c0666..284c27c7 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift @@ -2,6 +2,7 @@ import ConversationServiceProvider import Foundation import JSONRPC import LanguageServerProtocol +import Preferences import Status import SuggestionBasic @@ -98,7 +99,71 @@ public func editorConfiguration(includeMCP: Bool) -> 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) } @@ -653,6 +718,20 @@ enum GitHubCopilotRequest { return .custom("copilot/byok/listApiKeys", dict, ClientRequest.NullHandler) } } + + // MARK: Thinking + struct GenerateThinkingTitle: GitHubCopilotRequestType { + typealias Response = GenerateThinkingTitleResponse + + var params: GenerateThinkingTitleParams + + var request: ClientRequest { + let data = (try? JSONEncoder().encode(params)) ?? Data() + let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) + return .custom("thinking/generateTitle", dict, ClientRequest.NullHandler) + } + } + } // MARK: Notifications @@ -688,6 +767,36 @@ public enum GitHubCopilotNotification { } } + public enum CompressionTrigger: String, Codable { + case preTurn = "pre-turn" + case postToolCall = "post-tool-call" + case manual = "manual" + } + + public struct CompressionStartedNotification: Codable { + public var conversationId: String + public var partitionId: Int + public var reason: CompressionTrigger + + public static func decode(fromParams params: JSONValue?) -> CompressionStartedNotification? { + try? JSONDecoder().decode(Self.self, from: (try? JSONEncoder().encode(params)) ?? Data()) + } + } + + public struct CompressionCompletedNotification: Codable { + public var conversationId: String + public var archivedPartitionId: Int + public var newPartitionId: Int + public var summaryLength: Int + public var turnCount: Int + public var durationMs: Int + public var contextInfo: ContextSizeInfo? + + public static func decode(fromParams params: JSONValue?) -> CompressionCompletedNotification? { + try? JSONDecoder().decode(Self.self, from: (try? JSONEncoder().encode(params)) ?? Data()) + } + } + public struct MCPRuntimeNotification: Codable { public enum MCPRuntimeLogLevel: String, Codable { case Info = "info" diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/Conversation.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/Conversation.swift index 95bff025..b2906139 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/Conversation.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/Conversation.swift @@ -35,6 +35,8 @@ public struct ConversationProgressReport: BaseConversationProgress { public let steps: [ConversationProgressStep]? public let editAgentRounds: [AgentRound]? public let parentTurnId: String? + public let thinking: Thinking? + public let contextSize: ContextSizeInfo? } public struct ConversationProgressEnd: BaseConversationProgress { @@ -105,6 +107,7 @@ struct TurnCreateParams: Codable { var references: [Reference]? var model: String? var modelProviderName: String? + var modelInfo: ConversationModelInfo? var workspaceFolder: String? var workspaceFolders: [WorkspaceFolder]? var chatMode: String? diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCPRegistry.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCPRegistry.swift index 00576e6d..fd1c8bf6 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCPRegistry.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCPRegistry.swift @@ -506,16 +506,16 @@ public enum ServerStatus: String, Codable { } public struct OfficialMeta: Codable { - public let status: ServerStatus - public let publishedAt: String - public let updatedAt: String - public let isLatest: Bool + public let status: ServerStatus? + public let publishedAt: String? + public let updatedAt: String? + public let isLatest: Bool? public init( - status: ServerStatus, - publishedAt: String, - updatedAt: String, - isLatest: Bool + status: ServerStatus? = nil, + publishedAt: String? = nil, + updatedAt: String? = nil, + isLatest: Bool? = nil ) { self.status = status self.publishedAt = publishedAt @@ -566,7 +566,7 @@ public struct MCPRegistryExtensionMeta: Codable { } public struct ServerMeta: Codable { - public let official: OfficialMeta + public let official: OfficialMeta? private let additionalProperties: [String: AnyCodable]? enum CodingKeys: String, CodingKey { @@ -574,7 +574,7 @@ public struct ServerMeta: Codable { } public init( - official: OfficialMeta, + official: OfficialMeta? = nil, additionalProperties: [String: AnyCodable]? = nil ) { self.official = official @@ -688,9 +688,9 @@ public struct MCPRegistryServerDetail: Codable { public struct MCPRegistryServerResponse : Codable { public let server: MCPRegistryServerDetail - public let meta: ServerMeta + public let meta: ServerMeta? - public init(server: MCPRegistryServerDetail, meta: ServerMeta) { + public init(server: MCPRegistryServerDetail, meta: ServerMeta? = nil) { self.server = server self.meta = meta } diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift index dbe9e370..b1f02fcc 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift @@ -75,6 +75,7 @@ public protocol GitHubCopilotConversationServiceType { references: [ConversationAttachedReference], model: String?, modelProviderName: String?, + reasoningEffort: String?, turns: [TurnSchema], agentMode: Bool, customChatModeId: String?, @@ -88,6 +89,7 @@ public protocol GitHubCopilotConversationServiceType { references: [ConversationAttachedReference], model: String?, modelProviderName: String?, + reasoningEffort: String?, workspaceFolder: String, workspaceFolders: [WorkspaceFolder]?, agentMode: Bool, @@ -101,6 +103,7 @@ public protocol GitHubCopilotConversationServiceType { func models() async throws -> [CopilotModel] func registerTools(tools: [LanguageModelToolInformation]) async throws -> [LanguageModelTool] func updateToolsStatus(params: UpdateToolsStatusParams) async throws -> [LanguageModelTool] + func generateThinkingTitle(params: GenerateThinkingTitleParams) async throws -> GenerateThinkingTitleResponse } protocol GitHubCopilotLSP { @@ -166,6 +169,12 @@ public extension Notification.Name { .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 { @@ -293,6 +302,7 @@ public class GitHubCopilotBaseService { "didChangeFeatureFlags": true, "stateDatabase": true, "subAgent": JSONValue(booleanLiteral: enableSubagent), + "mcpAllowlist": true, ], "githubAppId": authAppId.map(JSONValue.string) ?? .null, ], @@ -646,6 +656,7 @@ public final class GitHubCopilotService: references: [ConversationAttachedReference], model: String?, modelProviderName: String?, + reasoningEffort: String?, turns: [TurnSchema], agentMode: Bool, customChatModeId: String?, @@ -679,6 +690,13 @@ public final class GitHubCopilotService: ignoredSkills: ignoredSkills, model: model, modelProviderName: modelProviderName, + modelInfo: (model != nil || reasoningEffort != nil) + ? ConversationModelInfo( + id: model, + providerName: modelProviderName, + reasoningEffort: reasoningEffort + ) + : nil, chatMode: agentMode ? "Agent" : nil, customChatModeId: customChatModeId, needToolCallConfirmation: true, @@ -703,13 +721,14 @@ public final class GitHubCopilotService: references: [ConversationAttachedReference], model: String?, modelProviderName: String?, + reasoningEffort: String?, workspaceFolder: String, workspaceFolders: [WorkspaceFolder]? = nil, agentMode: Bool, customChatModeId: String? ) async throws -> ConversationCreateResponse { do { - let params = TurnCreateParams(workDoneToken: workDoneToken, + var params = TurnCreateParams(workDoneToken: workDoneToken, conversationId: conversationId, turnId: turnId, message: message, @@ -723,6 +742,9 @@ public final class GitHubCopilotService: chatMode: agentMode ? "Agent" : nil, customChatModeId: customChatModeId, needToolCallConfirmation: true) + if model != nil || reasoningEffort != nil { + params.modelInfo = ConversationModelInfo(id: model, providerName: modelProviderName, reasoningEffort: reasoningEffort) + } return try await sendRequest( GitHubCopilotRequest.CreateTurn(params: params)) } catch { @@ -803,6 +825,11 @@ public final class GitHubCopilotService: } } + @GitHubCopilotSuggestionActor + public func generateThinkingTitle(params: GenerateThinkingTitleParams) async throws -> GenerateThinkingTitleResponse { + try await sendRequest(GitHubCopilotRequest.GenerateThinkingTitle(params: params)) + } + @GitHubCopilotSuggestionActor public func registerTools(tools: [LanguageModelToolInformation]) async throws -> [LanguageModelTool] { do { @@ -1455,12 +1482,34 @@ public final class GitHubCopilotService: await sendConfigurationUpdate() // Combine both notification streams - let combinedNotifications = Publishers.Merge3( - 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 e7f9eba9..2bacb348 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/ServerNotificationHandler.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/ServerNotificationHandler.swift @@ -14,6 +14,9 @@ class ServerNotificationHandlerImpl: ServerNotificationHandler { var conversationProgressHandler: ConversationProgressHandler = ConversationProgressHandlerImpl.shared var featureFlagNotifier: FeatureFlagNotifier = FeatureFlagNotifierImpl.shared var copilotPolicyNotifier: CopilotPolicyNotifier = CopilotPolicyNotifierImpl.shared + var compressionHandler: CompressionHandler = CompressionHandlerImpl.shared + var rateLimitNotifier: RateLimitNotifier = RateLimitNotifierImpl.shared + var quotaNotifier: QuotaNotifier = QuotaNotifierImpl.shared init() { self.protocolProgressSubject = PassthroughSubject() @@ -54,6 +57,45 @@ class ServerNotificationHandlerImpl: ServerNotificationHandler { copilotPolicyNotifier.handleCopilotPolicyNotification(policy) } break + case "$/copilot/compressionStarted": + if let payload = GitHubCopilotNotification.CompressionStartedNotification + .decode(fromParams: notification.params) { + compressionHandler.onCompressionStarted.send(payload.conversationId) + } + break + case "$/copilot/compressionCompleted": + if let payload = GitHubCopilotNotification.CompressionCompletedNotification + .decode(fromParams: notification.params) { + compressionHandler.onCompressionCompleted.send(payload) + } + break + case "$/copilot/rateLimitWarning": + if let data = try? JSONEncoder().encode(notification.params), + let params = try? JSONDecoder().decode( + RateLimitWarningParams.self, + from: data + ) { + rateLimitNotifier.handleRateLimitWarning(params) + } + break + case "copilot/quotaChange": + if let data = try? JSONEncoder().encode(notification.params), + let params = try? JSONDecoder().decode( + QuotaChangeParams.self, + from: data + ) { + quotaNotifier.handleQuotaChange(params) + } + break + case "copilot/quotaWarning": + if let data = try? JSONEncoder().encode(notification.params), + let params = try? JSONDecoder().decode( + QuotaWarningParams.self, + from: data + ) { + quotaNotifier.handleQuotaWarning(params) + } + break default: break } diff --git a/Tool/Sources/GitHubCopilotService/Services/CopilotPolicyNotifier.swift b/Tool/Sources/GitHubCopilotService/Services/CopilotPolicyNotifier.swift index e1d07f4c..5072ae12 100644 --- a/Tool/Sources/GitHubCopilotService/Services/CopilotPolicyNotifier.swift +++ b/Tool/Sources/GitHubCopilotService/Services/CopilotPolicyNotifier.swift @@ -12,12 +12,14 @@ public struct CopilotPolicy: Hashable, Codable { 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" } } diff --git a/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift b/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift index 8b343d61..23fd920f 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 } } @@ -91,11 +94,10 @@ public class FeatureFlagNotifierImpl: FeatureFlagNotifier { } private func updateFeatureFlags() { - let xcodeChat = self.didChangeFeatureFlagsParams.envelope["xcode_chat"]?.boolValue != false let chatEnabled = self.didChangeFeatureFlagsParams.envelope["chat_enabled"]?.boolValue != false self.featureFlags.restrictedTelemetry = self.didChangeFeatureFlagsParams.token["rt"] != "0" self.featureFlags.snippy = self.didChangeFeatureFlagsParams.token["sn"] != "0" - self.featureFlags.chat = xcodeChat && chatEnabled + self.featureFlags.chat = chatEnabled self.featureFlags.inlineChat = chatEnabled self.featureFlags.agentMode = self.didChangeFeatureFlagsParams.token["agent_mode"] != "0" self.featureFlags.mcp = self.didChangeFeatureFlagsParams.token["mcp"] != "0" @@ -103,6 +105,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 b99c854f..0038a1e1 100644 --- a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift +++ b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift @@ -57,6 +57,7 @@ public final class GitHubCopilotConversationService: ConversationServiceType { references: request.references ?? [], model: request.model, modelProviderName: request.modelProviderName, + reasoningEffort: request.reasoningEffort, turns: request.turns, agentMode: request.agentMode, customChatModeId: request.customChatModeId, @@ -79,6 +80,7 @@ public final class GitHubCopilotConversationService: ConversationServiceType { references: request.references ?? [], model: request.model, modelProviderName: request.modelProviderName, + reasoningEffort: request.reasoningEffort, workspaceFolder: workspace.projectURL.absoluteString, workspaceFolders: getWorkspaceFolders(workspace: workspace), agentMode: request.agentMode, @@ -116,9 +118,8 @@ public final class GitHubCopilotConversationService: ConversationServiceType { public func modes(workspace: WorkspaceInfo) async throws -> [ConversationMode]? { guard let service = await serviceLocator.getService(from: workspace) else { return nil } - let isPreviewEnabled = FeatureFlagNotifierImpl.shared.featureFlags.editorPreviewFeatures let isCustomAgentEnabled = CopilotPolicyNotifierImpl.shared.copilotPolicy.customAgentEnabled - let workspaceFolders = isPreviewEnabled && isCustomAgentEnabled ? getWorkspaceFolders( + let workspaceFolders = isCustomAgentEnabled ? getWorkspaceFolders( workspace: workspace ) : nil return try await service.modes(workspaceFolders: workspaceFolders) @@ -154,5 +155,13 @@ public final class GitHubCopilotConversationService: ConversationServiceType { workspaceFolders: getWorkspaceFolders(workspace: workspace)) ) } + + public func generateThinkingTitle( + workspace: WorkspaceInfo, + params: GenerateThinkingTitleParams + ) async throws -> GenerateThinkingTitleResponse? { + guard let service = await serviceLocator.getService(from: workspace) else { return nil } + return try await service.generateThinkingTitle(params: params) + } } diff --git a/Tool/Sources/GitHubCopilotService/Services/QuotaNotifier.swift b/Tool/Sources/GitHubCopilotService/Services/QuotaNotifier.swift new file mode 100644 index 00000000..a526e979 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/Services/QuotaNotifier.swift @@ -0,0 +1,257 @@ +import AppKit +import Combine +import Foundation +import Logger +import NotificationCenterCoordinator +import Status +import UserNotifications + +public struct QuotaSnapshotNotificationParams: Hashable, Codable { + public var quota: Int + public var used: Int + public var percentRemaining: Double + public var overageUsed: Int + public var overageEnabled: Bool + public var resetDate: String + public var unlimited: Bool +} + +public struct QuotaChangeParams: Codable { + public var chat: QuotaSnapshotNotificationParams? + public var completions: QuotaSnapshotNotificationParams? + public var premiumInteractions: QuotaSnapshotNotificationParams? + public var copilotPlan: String? + public var canUpgradePlan: Bool? + + enum CodingKeys: String, CodingKey { + case chat + case completions + case premiumInteractions = "premium_interactions" + case copilotPlan + case canUpgradePlan + } +} + +public struct QuotaWarningParams: Hashable, Codable { + public var title: String + public var message: String + public var severity: String // "warning" or "info" + public var chat: QuotaSnapshotNotificationParams? + public var completions: QuotaSnapshotNotificationParams? + public var premiumInteractions: QuotaSnapshotNotificationParams? + public var copilotPlan: String? + public var canUpgradePlan: Bool? + + enum CodingKeys: String, CodingKey { + case title + case message + case severity + case chat + case completions + case premiumInteractions = "premium_interactions" + case copilotPlan + case canUpgradePlan + } +} + +public protocol QuotaNotifier { + func handleQuotaChange(_ params: QuotaChangeParams) + func handleQuotaWarning(_ params: QuotaWarningParams) +} + +public class QuotaNotifierImpl: NSObject, QuotaNotifier { + public static let shared = QuotaNotifierImpl() + + private static let enableUsageActionIdentifier = "quotaEnableUsageAction" + private static let increaseBudgetActionIdentifier = "quotaIncreaseBudgetAction" + private static let upgradeActionIdentifier = "quotaUpgradeAction" + private static let categoryNone = "quotaWarning_none" + private static let categoryUpgrade = "quotaWarning_upgrade" + private static let categoryEnableUsage = "quotaWarning_enableUsage" + private static let categoryEnableUsageUpgrade = "quotaWarning_enableUsage_upgrade" + private static let categoryIncreaseBudget = "quotaWarning_increaseBudget" + private static let categoryIncreaseBudgetUpgrade = "quotaWarning_increaseBudget_upgrade" + + private var areCategoriesRegistered = false + + private override init() { + super.init() + } + + private func registerCategoriesIfNeeded() { + guard !areCategoriesRegistered else { return } + areCategoriesRegistered = true + + let enableUsageAction = UNNotificationAction( + identifier: Self.enableUsageActionIdentifier, + title: "Enable additional usage", + options: [.foreground] + ) + let increaseBudgetAction = UNNotificationAction( + identifier: Self.increaseBudgetActionIdentifier, + title: "Increase budget", + options: [.foreground] + ) + let upgradeAction = UNNotificationAction( + identifier: Self.upgradeActionIdentifier, + title: "Upgrade Plan", + options: [.foreground] + ) + + let handler: (UNNotificationResponse) -> Void = { response in + switch response.actionIdentifier { + case Self.enableUsageActionIdentifier, Self.increaseBudgetActionIdentifier: + NSWorkspace.shared.open(URL(string: QuotaFormatting.manageOverageURL)!) + case Self.upgradeActionIdentifier: + NSWorkspace.shared.open(URL(string: QuotaFormatting.upgradePlanURL)!) + default: + break + } + } + + let definitions: [(String, [UNNotificationAction])] = [ + (Self.categoryNone, []), + (Self.categoryUpgrade, [upgradeAction]), + (Self.categoryEnableUsage, [enableUsageAction]), + (Self.categoryEnableUsageUpgrade, [enableUsageAction, upgradeAction]), + (Self.categoryIncreaseBudget, [increaseBudgetAction]), + (Self.categoryIncreaseBudgetUpgrade, [increaseBudgetAction, upgradeAction]), + ] + for (id, actions) in definitions { + let category = UNNotificationCategory( + identifier: id, + actions: actions, + intentIdentifiers: [], + options: [] + ) + NotificationCenterCoordinator.shared.register( + category: category, + handler: handler, + for: id + ) + } + } + + private func notificationCategoryID(for actions: [WarningAction]) -> String { + let manageURL = QuotaFormatting.manageOverageURL + let upgradeURL = QuotaFormatting.upgradePlanURL + let manageAction = actions.first { $0.url.absoluteString == manageURL } + let hasUpgrade = actions.contains { $0.url.absoluteString == upgradeURL } + switch (manageAction?.title, hasUpgrade) { + case (nil, false): return Self.categoryNone + case (nil, true): return Self.categoryUpgrade + case ("Increase budget", false): return Self.categoryIncreaseBudget + case ("Increase budget", true): return Self.categoryIncreaseBudgetUpgrade + case (_, false): return Self.categoryEnableUsage + case (_, true): return Self.categoryEnableUsageUpgrade + } + } + + public func handleQuotaChange(_ params: QuotaChangeParams) { + Task { + guard var quotaInfo = await Status.shared.getQuotaInfo() else { return } + if let chat = params.chat { + quotaInfo.chat = QuotaSnapshot(from: chat) + } + if let completions = params.completions { + quotaInfo.completions = QuotaSnapshot(from: completions) + } + if let premium = params.premiumInteractions { + quotaInfo.premiumInteractions = QuotaSnapshot(from: premium) + } + if let plan = params.copilotPlan { + quotaInfo.copilotPlan = plan + } + if let canUpgradePlan = params.canUpgradePlan { + quotaInfo.canUpgradePlan = canUpgradePlan + } + let resetDate = params.chat?.resetDate + ?? params.completions?.resetDate + ?? params.premiumInteractions?.resetDate + if let date = resetDate { + quotaInfo.resetDate = date + } + await Status.shared.updateQuotaInfo(quotaInfo) + } + } + + public func handleQuotaWarning(_ params: QuotaWarningParams) { + Task { @MainActor in + let quotaInfo = await Status.shared.getQuotaInfo() + let actions = buildWarningActions(params: params, quotaInfo: quotaInfo) + let isCompletionsWarning = params.message.localizedCaseInsensitiveContains("completions") + if !isCompletionsWarning { + WarningStateManager.shared.setWarning(WarningContent( + message: params.message, + severity: params.severity, + actions: actions + )) + } + await NotificationCenterCoordinator.shared.setupIfNeeded() + self.registerCategoriesIfNeeded() + await sendAppleNotification(params, categoryID: notificationCategoryID(for: actions)) + } + } + + private func buildWarningActions( + params: QuotaWarningParams, + quotaInfo: GitHubCopilotQuotaInfo? + ) -> [WarningAction] { + let overageEnabled = params.premiumInteractions?.overageEnabled + ?? quotaInfo?.premiumInteractions?.overagePermitted + ?? false + let canUpgrade = params.canUpgradePlan ?? quotaInfo?.isUpgradePlanAllowed ?? true + + let overageAction = WarningAction( + title: overageEnabled ? "Increase budget" : "Enable additional usage", + url: URL(string: QuotaFormatting.manageOverageURL)! + ) + let upgradeAction = WarningAction( + title: "Upgrade plan", + url: URL(string: QuotaFormatting.upgradePlanURL)! + ) + + var actions: [WarningAction] = [] + if quotaInfo?.isPaidIndividual ?? false { + actions.append(overageAction) + } + if canUpgrade { + actions.append(upgradeAction) + } + return actions + } + + @MainActor + private func sendAppleNotification(_ params: QuotaWarningParams, categoryID: String) async { + let content = UNMutableNotificationContent() + content.title = params.title + content.body = params.message + content.sound = .default + content.categoryIdentifier = categoryID + + let request = UNNotificationRequest( + identifier: "quotaWarning", + content: content, + trigger: nil + ) + + do { + try await UNUserNotificationCenter.current().add(request) + } catch { + Logger.gitHubCopilot.error("Failed to show quota warning notification: \(error)") + } + } +} + +private extension QuotaSnapshot { + init(from params: QuotaSnapshotNotificationParams) { + self.init( + percentRemaining: Float(params.percentRemaining), + unlimited: params.unlimited, + overagePermitted: params.overageEnabled, + overageCount: Float(params.overageUsed), + entitlement: Double(params.quota), + quotaRemaining: Double(params.quota - params.used) + ) + } +} diff --git a/Tool/Sources/GitHubCopilotService/Services/RateLimitNotifier.swift b/Tool/Sources/GitHubCopilotService/Services/RateLimitNotifier.swift new file mode 100644 index 00000000..d8aa5561 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/Services/RateLimitNotifier.swift @@ -0,0 +1,100 @@ +import AppKit +import Combine +import Foundation +import Logger +import NotificationCenterCoordinator +import UserNotifications + +public struct UsageRateLimit: Hashable, Codable { + public var entitlement: Int + public var percentRemaining: Double + public var resetDate: String +} + +public struct RateLimitWarningParams: Hashable, Codable { + public var type: String // "weekly" or "session" + public var rateLimit: UsageRateLimit + public var message: String +} + +public protocol RateLimitNotifier { + func handleRateLimitWarning(_ params: RateLimitWarningParams) +} + +public class RateLimitNotifierImpl: NSObject, RateLimitNotifier { + public static let shared = RateLimitNotifierImpl() + + private static let categoryIdentifier = "rateLimitWarningCategory" + private static let learnMoreActionIdentifier = "rateLimitLearnMoreAction" + private static let learnMoreURL = URL( + string: "https://aka.ms/github-copilot-rate-limit-error" + )! + + private var isCategoryRegistered = false + + private override init() { + super.init() + } + + private func registerCategoryIfNeeded() { + guard !isCategoryRegistered else { return } + isCategoryRegistered = true + + let learnMoreAction = UNNotificationAction( + identifier: Self.learnMoreActionIdentifier, + title: "Learn more", + options: [.foreground] + ) + let category = UNNotificationCategory( + identifier: Self.categoryIdentifier, + actions: [learnMoreAction], + intentIdentifiers: [], + options: [] + ) + + NotificationCenterCoordinator.shared.register( + category: category, + handler: { response in + if response.actionIdentifier == Self.learnMoreActionIdentifier { + NSWorkspace.shared.open(Self.learnMoreURL) + } + }, + for: Self.categoryIdentifier + ) + } + + public func handleRateLimitWarning(_ params: RateLimitWarningParams) { + WarningStateManager.shared.setWarning(WarningContent( + message: params.message, + severity: "warning", + actions: [WarningAction(title: "Learn more", url: Self.learnMoreURL)] + )) + + Task { @MainActor in + await NotificationCenterCoordinator.shared.setupIfNeeded() + self.registerCategoryIfNeeded() + await sendAppleNotification(params) + } + } + + @MainActor + private func sendAppleNotification(_ params: RateLimitWarningParams) async { + let content = UNMutableNotificationContent() + content.title = "GitHub Copilot for Xcode" + content.body = params.message + content.sound = .default + content.categoryIdentifier = Self.categoryIdentifier + + let request = UNNotificationRequest( + identifier: "rateLimitWarning", + content: content, + trigger: nil + ) + + do { + try await UNUserNotificationCenter.current().add(request) + } catch { + Logger.gitHubCopilot.error("Failed to show rate limit notification: \(error)") + } + } +} diff --git a/Tool/Sources/GitHubCopilotService/Services/WarningState.swift b/Tool/Sources/GitHubCopilotService/Services/WarningState.swift new file mode 100644 index 00000000..ef5577e3 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/Services/WarningState.swift @@ -0,0 +1,53 @@ +import Foundation +import Status + +public struct WarningAction: Equatable { + public var title: String + public var url: URL + + public init(title: String, url: URL) { + self.title = title + self.url = url + } +} + +public struct WarningContent: Equatable { + public var message: String + public var severity: String // "warning" or "info" + public var actions: [WarningAction] // 0-2 CTAs + + public init(message: String, severity: String, actions: [WarningAction] = []) { + self.message = message + self.severity = severity + self.actions = actions + } +} + +public class WarningStateManager: ObservableObject { + public static let shared = WarningStateManager() + + @Published public var currentWarning: WarningContent? + + private init() { + DistributedNotificationCenter.default().addObserver( + forName: .authStatusDidChange, + object: nil, + queue: .main + ) { [weak self] _ in + self?.dismissWarning() + } + } + + public func setWarning(_ warning: WarningContent) { + DispatchQueue.main.async { [weak self] in + guard self?.currentWarning != warning else { return } + self?.currentWarning = warning + } + } + + public func dismissWarning() { + DispatchQueue.main.async { [weak self] in + self?.currentWarning = nil + } + } +} diff --git a/Tool/Sources/HostAppActivator/HostAppActivator.swift b/Tool/Sources/HostAppActivator/HostAppActivator.swift index 64afc07a..2274c13d 100644 --- a/Tool/Sources/HostAppActivator/HostAppActivator.swift +++ b/Tool/Sources/HostAppActivator/HostAppActivator.swift @@ -9,6 +9,8 @@ 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 @@ -43,17 +45,14 @@ public func launchHostAppSettings() throws { let activated = hostApp.activate(options: [.activateIgnoringOtherApps]) Logger.ui.info("\(hostAppName()) activated: \(activated)") - let scriptSuccess = tryLaunchWithAppleScript() - - // If AppleScript fails, fall back to notification center - if !scriptSuccess { - DistributedNotificationCenter.default().postNotificationName( - .openSettingsWindowRequest, - object: nil - ) - Logger.ui.info("\(hostAppName()) settings notification sent after activation") - return - } + _ = tryLaunchWithAppleScript() + + DistributedNotificationCenter.default().postNotificationName( + .openSettingsWindowRequest, + object: nil + ) + Logger.ui.info("\(hostAppName()) settings notification sent after activation") + return } else { // If app is not running, launch it with the settings flag try launchHostAppWithArgs(args: ["--settings"]) @@ -89,6 +88,27 @@ public func launchHostAppToolsSettings(currentAgentSubMode: String) 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() { diff --git a/Tool/Sources/NotificationCenterCoordinator/NotificationCenterCoordinator.swift b/Tool/Sources/NotificationCenterCoordinator/NotificationCenterCoordinator.swift new file mode 100644 index 00000000..4e69163e --- /dev/null +++ b/Tool/Sources/NotificationCenterCoordinator/NotificationCenterCoordinator.swift @@ -0,0 +1,82 @@ +import Foundation +import UserNotifications + +/// A single shared delegate for `UNUserNotificationCenter`. +/// +/// `UNUserNotificationCenter` has only one `delegate` and one set of categories. +/// Multiple features (rate limit warnings, show-message requests, etc.) need to +/// post notifications and handle action taps, so they register handlers here +/// keyed by category identifier instead of each assigning themselves as the +/// delegate. +public final class NotificationCenterCoordinator: NSObject, UNUserNotificationCenterDelegate { + public static let shared = NotificationCenterCoordinator() + + public typealias ActionHandler = (UNNotificationResponse) -> Void + + private var isNotificationSetup = false + private var categories: [String: UNNotificationCategory] = [:] + private var actionHandlers: [String: ActionHandler] = [:] + private let lock = NSLock() + + private override init() { + super.init() + } + + /// Ensures the notification center delegate is set and authorization has + /// been requested. Safe to call multiple times. + @MainActor + public func setupIfNeeded() async { + guard !isNotificationSetup else { return } + guard Bundle.main.bundleIdentifier != nil else { + // Skip notification setup in test environment. + return + } + isNotificationSetup = true + UNUserNotificationCenter.current().delegate = self + _ = try? await UNUserNotificationCenter.current() + .requestAuthorization(options: [.alert, .sound]) + } + + /// Registers a category (optional) and an action handler for notifications + /// whose `categoryIdentifier` matches. + public func register( + category: UNNotificationCategory?, + handler: @escaping ActionHandler, + for categoryIdentifier: String + ) { + lock.lock() + if let category { + categories[categoryIdentifier] = category + } + actionHandlers[categoryIdentifier] = handler + let allCategories = Set(categories.values) + lock.unlock() + + UNUserNotificationCenter.current().setNotificationCategories(allCategories) + } + + // MARK: - UNUserNotificationCenterDelegate + + public func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + completionHandler([.banner, .list, .badge, .sound]) + } + + public func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + let categoryIdentifier = response.notification.request.content.categoryIdentifier + lock.lock() + let handler = actionHandlers[categoryIdentifier] + lock.unlock() + Task { @MainActor in + handler?(response) + completionHandler() + } + } +} diff --git a/Tool/Sources/Persist/Storage/ConversationStorage/ConversationStorage.swift b/Tool/Sources/Persist/Storage/ConversationStorage/ConversationStorage.swift index 2ec2f53c..765d35e4 100644 --- a/Tool/Sources/Persist/Storage/ConversationStorage/ConversationStorage.swift +++ b/Tool/Sources/Persist/Storage/ConversationStorage/ConversationStorage.swift @@ -80,8 +80,6 @@ public final class ConversationStorage: ConversationStorageProtocol { try withDBTransaction { db in - let now = Date().timeIntervalSince1970 - for operation in request.operations { switch operation { case .upsertConversation(let conversationItems): @@ -137,7 +135,7 @@ public final class ConversationStorage: ConversationStorageProtocol { let table = turnTable.table let column = turnTable.column - var query = table + let query = table .filter(column.conversationID == conversationID) .order(column.rowID.asc) let rowIterator = try db.prepareRowIterator(query) diff --git a/Tool/Sources/Preferences/Keys.swift b/Tool/Sources/Preferences/Keys.swift index 93f8a80b..400c07e3 100644 --- a/Tool/Sources/Preferences/Keys.swift +++ b/Tool/Sources/Preferences/Keys.swift @@ -336,6 +336,10 @@ public extension UserDefaultPreferenceKeys { var enableSubagent: PreferenceKey { .init(defaultValue: true, key: "EnableSubagent") } + + var autoCompress: PreferenceKey { + .init(defaultValue: true, key: "AutoCompress") + } } // MARK: - Theme @@ -624,3 +628,27 @@ public extension UserDefaultPreferenceKeys { .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/Locale.swift b/Tool/Sources/Preferences/Types/Locale.swift index 6b50d82d..6bd25fe2 100644 --- a/Tool/Sources/Preferences/Types/Locale.swift +++ b/Tool/Sources/Preferences/Types/Locale.swift @@ -2,14 +2,14 @@ import Foundation public extension Locale { static var availableLocalizedLocales: [String] { - let localizedLocales = Locale.isoLanguageCodes.compactMap { - Locale(identifier: "en-US").localizedString(forLanguageCode: $0) + let localizedLocales = Locale.LanguageCode.isoLanguageCodes.compactMap { + Locale(identifier: "en-US").localizedString(forLanguageCode: $0.identifier) } .sorted() return localizedLocales } var languageName: String { - localizedString(forLanguageCode: languageCode ?? "") ?? "" + localizedString(forLanguageCode: language.languageCode?.identifier ?? "") ?? "" } } 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 d322f321..9055c3c3 100644 --- a/Tool/Sources/Preferences/UserDefaults.swift +++ b/Tool/Sources/Preferences/UserDefaults.swift @@ -10,6 +10,12 @@ 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) @@ -19,6 +25,8 @@ public extension UserDefaults { 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( @@ -81,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) @@ -311,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/Tool/Sources/SharedUIComponents/ConditionalFontWeight.swift b/Tool/Sources/SharedUIComponents/ConditionalFontWeight.swift index 55cc15c7..b09c94ba 100644 --- a/Tool/Sources/SharedUIComponents/ConditionalFontWeight.swift +++ b/Tool/Sources/SharedUIComponents/ConditionalFontWeight.swift @@ -8,11 +8,7 @@ public struct ConditionalFontWeight: ViewModifier { } public func body(content: Content) -> some View { - if #available(macOS 13.0, *), weight != nil { - content.fontWeight(weight) - } else { - content - } + content.fontWeight(weight) } } diff --git a/Tool/Sources/SharedUIComponents/CustomScrollView.swift b/Tool/Sources/SharedUIComponents/CustomScrollView.swift index 0eb486f0..91c73fae 100644 --- a/Tool/Sources/SharedUIComponents/CustomScrollView.swift +++ b/Tool/Sources/SharedUIComponents/CustomScrollView.swift @@ -44,11 +44,7 @@ public struct CustomScrollView: View { } .listStyle(.plain) .modify { view in - if #available(macOS 13.0, *) { - view.listRowSeparator(.hidden).listSectionSeparator(.hidden) - } else { - view - } + view.listRowSeparator(.hidden).listSectionSeparator(.hidden) } .frame(idealHeight: max(10, height)) .onPreferenceChange(CustomScrollViewHeightPreferenceKey.self) { newHeight in diff --git a/Tool/Sources/SharedUIComponents/FontPicker.swift b/Tool/Sources/SharedUIComponents/FontPicker.swift index 2f91c9d0..cc2f4f4a 100644 --- a/Tool/Sources/SharedUIComponents/FontPicker.swift +++ b/Tool/Sources/SharedUIComponents/FontPicker.swift @@ -14,17 +14,10 @@ public struct FontPicker: View { } public var body: some View { - if #available(macOS 13.0, *) { - LabeledContent { - button - } label: { - label - } - } else { - HStack { - label - button - } + LabeledContent { + button + } label: { + label } } diff --git a/Tool/Sources/SharedUIComponents/QuotaPopoverView.swift b/Tool/Sources/SharedUIComponents/QuotaPopoverView.swift new file mode 100644 index 00000000..0157da2b --- /dev/null +++ b/Tool/Sources/SharedUIComponents/QuotaPopoverView.swift @@ -0,0 +1,332 @@ +import SwiftUI +import Status + +// MARK: - Quota Popover View + +public struct QuotaPopoverView: View { + let quotaInfo: GitHubCopilotQuotaInfo? + + public init(quotaInfo: GitHubCopilotQuotaInfo?) { + self.quotaInfo = quotaInfo + } + + private var isUnlimited: Bool { + quotaInfo?.isCBCEUnlimited == true + } + + public var body: some View { + VStack(alignment: .leading, spacing: 8) { + if let quotaInfo = quotaInfo { + // Plan name + HStack { + Text(quotaInfo.planDisplayName) + .font(.system(size: 13, weight: .semibold)) + + Spacer() + + Button(action: { openURL(QuotaFormatting.settingsURL) }) { + Image(systemName: "slider.horizontal.3") + .font(.system(size: 12)) + .foregroundColor(.secondary) + } + .buttonStyle(HoverButtonStyle()) + .help("Open Copilot Settings") + .accessibilityLabel("Open Copilot Settings") + } + + quotaContent(quotaInfo) + } else { + Text("No usage data available.") + .font(.system(size: 12)) + .foregroundColor(.secondary) + } + } + .padding(isUnlimited + ? EdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12) + : EdgeInsets(top: 18, leading: 16, bottom: 18, trailing: 16)) + .frame( + minWidth: isUnlimited ? 260 : 320, + idealWidth: isUnlimited ? 300 : 400, + maxWidth: .infinity + ) + .fixedSize(horizontal: false, vertical: true) + } + + @ViewBuilder + private func quotaContent(_ info: GitHubCopilotQuotaInfo) -> some View { + if info.isCBCEUnlimited { + Text("You have no monthly limit on AI credits usage set by your organization.") + .font(.system(size: 12)) + .foregroundColor(.secondary) + } else { + let items = buildQuotaItems(info) + + ForEach(items, id: \.title) { item in + quotaRow(item) + } + + if !info.isFreeUser { + let overagePermitted = info.overagePermitted + let isNonTBBPaid = !info.isTokenBasedBilling && !info.isCBCE + let overageLabel: String = isNonTBBPaid + ? (overagePermitted ? "Additional paid premium requests enabled." : "Additional paid premium requests disabled.") + : (overagePermitted ? "Additional usage enabled" : "Additional usage not enabled") + let overageTooltip: String = info.isCBCE + ? (overagePermitted + ? "Usage will continue until limits are reset." + : "Usage will pause if the monthly usage limit is reached. Request additional usage from your administrator.") + : "Pay-as-you-go usage of additional AI credits once you run out of your included usage. Set a budget to cap your maximum monthly spend." + HStack(spacing: 2) { + Text(overageLabel) + .scaledFont(size: 13) + .foregroundColor(overagePermitted ? .primary : .secondary) + + if !isNonTBBPaid { + Image(systemName: "info.circle") + .scaledFont(size: 10) + .foregroundColor(.secondary) + .help(overageTooltip) + } + } + } + + if !info.isCBCE, shouldShowQuotaButtons(info) { + quotaButtons(info) + } + } + } + + private func shouldShowQuotaButtons(_ info: GitHubCopilotQuotaInfo) -> Bool { + let snapshots: [QuotaSnapshot] = [info.premiumInteractions, info.chat, info.completions].compactMap { $0 } + for snapshot in snapshots { + if snapshot.unlimited { continue } + if let used = snapshot.usedPercentage, used >= 75 { + return true + } + } + return false + } + + @ViewBuilder + private func quotaButtons(_ info: GitHubCopilotQuotaInfo) -> some View { + let canUpgrade = info.isUpgradePlanAllowed + let hasOverage = info.isPaidIndividual + let overageTitle: String = info.isTokenBasedBilling + ? (info.overagePermitted ? "Increase Budget" : "Enable Additional Usage") + : "Manage Paid Premium Requests" + let upgradeTitle = (!info.isTokenBasedBilling && info.isFreeUser) ? "Upgrade to Pro" : "Upgrade Plan" + + HStack(spacing: 8) { + if hasOverage { + actionButton(title: overageTitle, urlString: QuotaFormatting.manageOverageURL, prominent: true) + } + if canUpgrade { + actionButton(title: upgradeTitle, urlString: QuotaFormatting.upgradePlanURL, prominent: hasOverage ? false : true) + } + } + } + + @ViewBuilder + private func actionButton(title: String, urlString: String, prominent: Bool) -> some View { + if prominent { + Button(action: { openURL(urlString) }) { + Text(title) + .scaledFont(size: 13, weight: .medium) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.regular) + } else { + Button(action: { openURL(urlString) }) { + Text(title) + .scaledFont(size: 13, weight: .medium) + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .controlSize(.regular) + } + } + + private func openURL(_ urlString: String) { + if let url = URL(string: urlString) { + NSWorkspace.shared.open(url) + } + } + + @ViewBuilder + private func quotaRow(_ item: QuotaItem) -> some View { + VStack(alignment: .leading, spacing: item.tightResetSpacing ? 2 : 8) { + VStack(alignment: .leading, spacing: 2) { + HStack { + Text(item.title) + .font(.system(size: 13)) + + if item.showInfoIcon { + Image(systemName: "info.circle") + .font(.system(size: 10)) + .foregroundColor(.secondary) + .help(item.tooltip) + } + + Spacer() + + if let parts = item.creditCountParts { + (Text(parts.used).foregroundColor(.primary) + + Text(parts.suffix).foregroundColor(.secondary)) + .font(.system(size: 11)) + } else { + Text(item.percentageText) + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + } + + if item.isUnlimited { + Text("You have no limit on AI credits usage") + .font(.system(size: 11)) + .foregroundColor(.secondary) + } else { + GeometryReader { geometry in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 1.5) + .fill(item.barColor.opacity(0.3)) + .frame(height: 3) + + RoundedRectangle(cornerRadius: 1.5) + .fill(item.barColor) + .frame(width: geometry.size.width * CGFloat(min(item.usedFraction, 1.0)), height: 3) + } + } + .frame(height: 3) + } + } + + if let resetText = item.resetText { + Text(resetText) + .font(.system(size: 10)) + .foregroundColor(.secondary) + } + } + } + + // MARK: - Data helpers + + private struct QuotaItem { + let title: String + let percentageText: String + let creditCountParts: (used: String, suffix: String)? + let usedFraction: Float + let isUnlimited: Bool + let barColor: Color + let tooltip: String + let resetText: String? + let showInfoIcon: Bool + let tightResetSpacing: Bool + } + + private func buildQuotaItems(_ info: GitHubCopilotQuotaInfo) -> [QuotaItem] { + var items: [QuotaItem] = [] + let showInfoIcon = info.isTokenBasedBilling && !info.isFreeUser && !info.isCBCE + let tightResetSpacing = !info.isFreeUser + let creditsTooltip = "AI credits included with your plan, reset monthly. Enable additional usage to continue with pay-as-you-go credits once you run out of your included usage." + + if info.isCBCE { + if let premium = info.premiumInteractions { + items.append(makeQuotaItem( + title: "Monthly Limit", + snapshot: premium, + tooltip: "", + resetAt: info.resetDateUtc ?? info.resetDate, + showInfoIcon: false, + tightResetSpacing: tightResetSpacing + )) + } + } else if info.isFreeUser { + let completionsTitle = info.isTokenBasedBilling ? "Inline Suggestions" : "Code Completions" + let chatTitle = info.isTokenBasedBilling ? "Included Credits" : "Chat Messages" + items.append(makeQuotaItem(title: completionsTitle, snapshot: info.completions, tooltip: "", resetAt: nil, showInfoIcon: showInfoIcon, tightResetSpacing: tightResetSpacing)) + let chatResetAt = info.isTokenBasedBilling ? (info.resetDateUtc ?? info.resetDate) : nil + let chatTooltip = info.isTokenBasedBilling ? creditsTooltip : "" + items.append(makeQuotaItem(title: chatTitle, snapshot: info.chat, tooltip: chatTooltip, resetAt: chatResetAt, showInfoIcon: showInfoIcon, tightResetSpacing: tightResetSpacing)) + } else if info.isTokenBasedBilling { + if let premium = info.premiumInteractions { + items.append(makeQuotaItem( + title: "Included Credits", + snapshot: premium, + tooltip: creditsTooltip, + resetAt: info.resetDateUtc ?? info.resetDate, + showInfoIcon: showInfoIcon, + tightResetSpacing: tightResetSpacing + )) + } + } else { + if let premium = info.premiumInteractions { + items.append(makeQuotaItem(title: "Premium Requests", snapshot: premium, tooltip: "", resetAt: nil, showInfoIcon: showInfoIcon, tightResetSpacing: tightResetSpacing)) + } + if !info.completions.unlimited { + items.append(makeQuotaItem(title: "Code Completions", snapshot: info.completions, tooltip: "", resetAt: nil, showInfoIcon: showInfoIcon, tightResetSpacing: tightResetSpacing)) + } + if !info.chat.unlimited { + items.append(makeQuotaItem(title: "Chat Messages", snapshot: info.chat, tooltip: "", resetAt: nil, showInfoIcon: showInfoIcon, tightResetSpacing: tightResetSpacing)) + } + } + + return items + } + + private func makeQuotaItem(title: String, snapshot: QuotaSnapshot, tooltip: String, resetAt: String?, showInfoIcon: Bool, tightResetSpacing: Bool) -> QuotaItem { + let usedPercentage = snapshot.usedPercentage + let showAsCreditCount = showInfoIcon + let percentageText: String + var creditCountParts: (used: String, suffix: String)? = nil + if snapshot.unlimited { + percentageText = "Included" + } else if showAsCreditCount, + let entitlement = snapshot.entitlement, + let remaining = snapshot.quotaRemaining { + let used = max(0, entitlement - remaining) + let usedStr = Int(used).formatted() + let totalStr = Int(entitlement).formatted() + creditCountParts = (used: usedStr, suffix: " / \(totalStr) AI credits") + percentageText = "\(usedStr) / \(totalStr) AI credits" + } else if let used = usedPercentage { + percentageText = QuotaFormatting.formatUsedPercentage(used) + } else if let remaining = snapshot.quotaRemaining { + percentageText = "\(Int(remaining)) remaining" + } else { + percentageText = "0%" + } + + let usedFraction = (usedPercentage ?? 0) / 100.0 + let barColor = progressBarColor(for: snapshot.usageLevel) + let noUsageYet = snapshot.usedPercentage == 0 || (snapshot.usedPercentage == nil && snapshot.percentRemaining == 100) + + let resetText: String? + if let resetAt = resetAt, !snapshot.unlimited { + resetText = noUsageYet ? "No usage yet" : QuotaFormatting.formatResetText(resetAt) + } else { + resetText = nil + } + + return QuotaItem( + title: title, + percentageText: percentageText, + creditCountParts: creditCountParts, + usedFraction: usedFraction, + isUnlimited: snapshot.unlimited, + barColor: barColor, + tooltip: tooltip, + resetText: resetText, + showInfoIcon: showInfoIcon, + tightResetSpacing: tightResetSpacing + ) + } + + private func progressBarColor(for level: QuotaSnapshot.UsageLevel) -> Color { + switch level { + case .critical: return .red + case .warning: return .yellow + case .healthy: return .blue + } + } +} 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/Tool/Sources/Status/Types/GitHubCopilotQuotaInfo.swift b/Tool/Sources/Status/Types/GitHubCopilotQuotaInfo.swift index 50ffc4f3..9df34403 100644 --- a/Tool/Sources/Status/Types/GitHubCopilotQuotaInfo.swift +++ b/Tool/Sources/Status/Types/GitHubCopilotQuotaInfo.swift @@ -1,17 +1,168 @@ import Foundation public struct QuotaSnapshot: Codable, Equatable, Hashable { - public var percentRemaining: Float + public var percentRemaining: Float? public var unlimited: Bool public var overagePermitted: Bool + public var overageCount: Float? + public var entitlement: Double? + public var quotaRemaining: Double? + public var timeStamp: String? + + public init( + percentRemaining: Float? = nil, + unlimited: Bool, + overagePermitted: Bool, + overageCount: Float? = nil, + entitlement: Double? = nil, + quotaRemaining: Double? = nil, + timeStamp: String? = nil + ) { + self.percentRemaining = percentRemaining + self.unlimited = unlimited + self.overagePermitted = overagePermitted + self.overageCount = overageCount + self.entitlement = entitlement + self.quotaRemaining = quotaRemaining + self.timeStamp = timeStamp + } + + /// Percentage of the quota that has been consumed (0–100), or nil when it can't be derived. + public var usedPercentage: Float? { + if let percentRemaining = percentRemaining { + return 100.0 - percentRemaining + } + if let entitlement = entitlement, entitlement > 0, let remaining = quotaRemaining { + return Float(max(0, min(100, ((entitlement - remaining) / entitlement) * 100))) + } + return nil + } + + /// Coarse health bucket used to pick progress-bar / status colors. + public enum UsageLevel { + case healthy // >25% remaining + case warning // 10–25% remaining + case critical // ≤10% remaining + } + + public var usageLevel: UsageLevel { + let percentRemaining = self.percentRemaining ?? (100.0 - (usedPercentage ?? 0)) + if percentRemaining <= 10 { return .critical } + if percentRemaining <= 25 { return .warning } + return .healthy + } } public struct GitHubCopilotQuotaInfo: Codable, Equatable, Hashable { public var chat: QuotaSnapshot public var completions: QuotaSnapshot - public var premiumInteractions: QuotaSnapshot + public var premiumInteractions: QuotaSnapshot? public var resetDate: String + public var resetDateUtc: String? // CB/CE User only public var copilotPlan: String - + public var tokenBasedBillingEnabled: Bool? + public var canUpgradePlan: Bool? + public var isFreeUser: Bool { copilotPlan == "free" } + public var isUpgradePlanAllowed: Bool { canUpgradePlan ?? true } + public var isTokenBasedBilling: Bool { tokenBasedBillingEnabled == true } + public var isCBCE: Bool { copilotPlan == "business" || copilotPlan == "enterprise" } + public var isCBCEUnlimited: Bool { isCBCE && (premiumInteractions?.unlimited ?? false) } + public var isPaidIndividual: Bool { + copilotPlan == "individual" || copilotPlan == "individual_pro" || copilotPlan == "individual_max" + } + public var overagePermitted: Bool { premiumInteractions?.overagePermitted ?? false } + + /// Human-readable plan name (e.g. "Copilot Pro Plan"). + public var planDisplayName: String { + switch copilotPlan { + case "free": return "Copilot Free Plan" + case "individual": return "Copilot Pro Plan" + case "individual_pro": return "Copilot Pro+ Plan" + case "individual_max": return "Copilot Max Plan" + case "business": return "Copilot Business Plan" + case "enterprise": return "Copilot Enterprise Plan" + default: return "Copilot Plan" + } + } + + public init( + chat: QuotaSnapshot, + completions: QuotaSnapshot, + premiumInteractions: QuotaSnapshot? = nil, + resetDate: String, + resetDateUtc: String? = nil, + copilotPlan: String, + tokenBasedBillingEnabled: Bool? = nil, + canUpgradePlan: Bool? = nil + ) { + self.chat = chat + self.completions = completions + self.premiumInteractions = premiumInteractions + self.resetDate = resetDate + self.resetDateUtc = resetDateUtc + self.copilotPlan = copilotPlan + self.tokenBasedBillingEnabled = tokenBasedBillingEnabled + self.canUpgradePlan = canUpgradePlan + } +} + +// MARK: - Shared formatting + +public enum QuotaFormatting { + public static let upgradePlanURL = "https://aka.ms/github-copilot-upgrade-plan" + public static let manageOverageURL = "https://aka.ms/github-copilot-manage-overage" + public static let settingsURL = "https://aka.ms/github-copilot-settings" + + /// Formats a percentage as "12% used" / "12.3% used". + public static func formatUsedPercentage(_ used: Float) -> String { + let numberPart = used.truncatingRemainder(dividingBy: 1) == 0 + ? String(format: "%.0f", used) + : String(format: "%.1f", used) + return "\(numberPart)% used" + } + + /// Formats a reset date string into "Resets in N days on MMM d, yyyy." text. + /// Accepts ISO8601 (with or without fractional seconds), `yyyy-MM-dd`, or `yyyy.MM.dd`. + public static func formatResetText(_ dateString: String) -> String { + guard let date = parseResetDate(dateString) else { + return "Resets on \(dateString)." + } + let days = max(0, Calendar.current.dateComponents([.day], from: Date(), to: date).day ?? 0) + let formattedDate = mediumDateFormatter.string(from: date) + return "Resets in \(days) \(days == 1 ? "day" : "days") on \(formattedDate)." + } + + private static func parseResetDate(_ dateString: String) -> Date? { + if let date = isoFractionalFormatter.date(from: dateString) { return date } + if let date = isoFormatter.date(from: dateString) { return date } + for formatter in shortDateFormatters { + if let date = formatter.date(from: dateString) { return date } + } + return nil + } + + private static let isoFractionalFormatter: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f + }() + + private static let isoFormatter: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime] + return f + }() + + private static let shortDateFormatters: [DateFormatter] = ["yyyy-MM-dd", "yyyy.MM.dd"].map { format in + let f = DateFormatter() + f.dateFormat = format + return f + } + + private static let mediumDateFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "MMM d, yyyy" + return f + }() } diff --git a/Tool/Sources/StatusBarItemView/QuotaView.swift b/Tool/Sources/StatusBarItemView/QuotaView.swift index 4f073716..de5386a6 100644 --- a/Tool/Sources/StatusBarItemView/QuotaView.swift +++ b/Tool/Sources/StatusBarItemView/QuotaView.swift @@ -1,61 +1,38 @@ import SwiftUI import Foundation - -// MARK: - QuotaSnapshot Model -public struct QuotaSnapshot { - public var percentRemaining: Float - public var unlimited: Bool - public var overagePermitted: Bool - - public init(percentRemaining: Float, unlimited: Bool, overagePermitted: Bool) { - self.percentRemaining = percentRemaining - self.unlimited = unlimited - self.overagePermitted = overagePermitted - } -} +import Status // MARK: - QuotaView Main Class public class QuotaView: NSView { - + // MARK: - Properties - private let chat: QuotaSnapshot - private let completions: QuotaSnapshot - private let premiumInteractions: QuotaSnapshot - private let resetDate: String - private let copilotPlan: String - - private var isFreeUser: Bool { - return copilotPlan == "free" - } - - private var isOrgUser: Bool { - return copilotPlan == "business" || copilotPlan == "enterprise" - } - + private let quotaInfo: GitHubCopilotQuotaInfo + + private var isFreeUser: Bool { quotaInfo.isFreeUser } + private var isCBCE: Bool { quotaInfo.isCBCE } + private var isCBCEUnlimited: Bool { quotaInfo.isCBCEUnlimited } + private var tokenBasedBillingEnabled: Bool { quotaInfo.isTokenBasedBilling } + private var isPaidIndividualUser: Bool { quotaInfo.isPaidIndividual } + private var canUpgradePlan: Bool { quotaInfo.isUpgradePlanAllowed } + private var isFreeQuotaUsedUp: Bool { - return chat.percentRemaining == 0 && completions.percentRemaining == 0 + let chatRemaining = quotaInfo.chat.percentRemaining ?? (100.0 - (quotaInfo.chat.usedPercentage ?? 0)) + let completionsRemaining = quotaInfo.completions.percentRemaining ?? (100.0 - (quotaInfo.completions.usedPercentage ?? 0)) + return chatRemaining == 0 && completionsRemaining == 0 } - + private var isFreeQuotaRemaining: Bool { - return chat.percentRemaining > 25 && completions.percentRemaining > 25 + let chatRemaining = quotaInfo.chat.percentRemaining ?? (100.0 - (quotaInfo.chat.usedPercentage ?? 0)) + let completionsRemaining = quotaInfo.completions.percentRemaining ?? (100.0 - (quotaInfo.completions.usedPercentage ?? 0)) + return chatRemaining > 25 && completionsRemaining > 25 } - + // MARK: - Initialization - public init( - chat: QuotaSnapshot, - completions: QuotaSnapshot, - premiumInteractions: QuotaSnapshot, - resetDate: String, - copilotPlan: String - ) { - self.chat = chat - self.completions = completions - self.premiumInteractions = premiumInteractions - self.resetDate = resetDate - self.copilotPlan = copilotPlan - + public init(quotaInfo: GitHubCopilotQuotaInfo) { + self.quotaInfo = quotaInfo + super.init(frame: NSRect(x: 0, y: 0, width: Layout.viewWidth, height: 0)) - + configureView() } @@ -80,24 +57,41 @@ public class QuotaView: NSView { // MARK: - Component Creation private func createViewComponents() -> ViewComponents { + let (upsellView, upsellHeight) = createUpsellView() return ViewComponents( titleContainer: createTitleContainer(), - progressViews: createProgressViews(), + progressViews: isCBCEUnlimited ? [] : createProgressViews(), statusMessageLabel: createStatusMessageLabel(), + unlimitedMessageLabel: isCBCEUnlimited ? createUnlimitedMessageLabel() : nil, + refreshTextLabel: (isCBCE && !isCBCEUnlimited) ? createRefreshTextLabel() : nil, resetTextLabel: createResetTextLabel(), - upsellLabel: createUpsellLabel() + upsellView: upsellView, + upsellHeight: upsellHeight ) } - + private func addSubviewsToHierarchy(_ components: ViewComponents) { addSubview(components.titleContainer) - components.progressViews.forEach { addSubview($0) } - if !isFreeUser { - addSubview(components.statusMessageLabel) + if isCBCEUnlimited { + if let label = components.unlimitedMessageLabel { + addSubview(label) + } + return } - addSubview(components.resetTextLabel) - if !(isOrgUser || (isFreeUser && isFreeQuotaRemaining)) { - addSubview(components.upsellLabel) + components.progressViews.forEach { addSubview($0) } + if isCBCE, let refreshLabel = components.refreshTextLabel { + if quotaInfo.premiumInteractions != nil { + addSubview(components.statusMessageLabel) + } + addSubview(refreshLabel) + } else { + if !isFreeUser, quotaInfo.premiumInteractions != nil || isPaidIndividualUser { + addSubview(components.statusMessageLabel) + } + addSubview(components.resetTextLabel) + if !(isCBCE || (isFreeUser && isFreeQuotaRemaining)) { + addSubview(components.upsellView) + } } } } @@ -165,28 +159,33 @@ extension QuotaView { // MARK: - Progress Bars Section extension QuotaView { private func createProgressViews() -> [NSView] { - let completionsView = createProgressBarSection( - title: "Code Completions", - snapshot: completions - ) - - let chatView = createProgressBarSection( - title: "Chat Messages", - snapshot: chat - ) - + var items: [(String, QuotaSnapshot)] = [] + if isFreeUser { - return [completionsView, chatView] + let completionsTitle = tokenBasedBillingEnabled ? "Inline Suggestions" : "Code Completions" + let chatTitle = tokenBasedBillingEnabled ? "Included Credits" : "Chat Messages" + items.append((completionsTitle, quotaInfo.completions)) + items.append((chatTitle, quotaInfo.chat)) + } else if tokenBasedBillingEnabled { + if let premiumInteractions = quotaInfo.premiumInteractions { + items.append(("Included Credits", premiumInteractions)) + } + } else { + // Original billing + if let premiumInteractions = quotaInfo.premiumInteractions { + items.append(("Premium Requests", premiumInteractions)) + } + if !quotaInfo.completions.unlimited { + items.append(("Code Completions", quotaInfo.completions)) + } + if !quotaInfo.chat.unlimited { + items.append(("Chat Messages", quotaInfo.chat)) + } } - - let premiumView = createProgressBarSection( - title: "Premium Requests", - snapshot: premiumInteractions - ) - - return [completionsView, chatView, premiumView] + + return items.map { createProgressBarSection(title: $0.0, snapshot: $0.1) } } - + private func createProgressBarSection(title: String, snapshot: QuotaSnapshot) -> NSView { let container = NSView() container.translatesAutoresizingMaskIntoConstraints = false @@ -215,24 +214,29 @@ extension QuotaView { } private func createPercentageLabel(snapshot: QuotaSnapshot) -> NSTextField { - let usedPercentage = (100.0 - snapshot.percentRemaining) - let numberPart = usedPercentage.truncatingRemainder(dividingBy: 1) == 0 - ? String(format: "%.0f", usedPercentage) - : String(format: "%.1f", usedPercentage) - let text = snapshot.unlimited ? "Included" : "\(numberPart)%" - + let text: String + if snapshot.unlimited { + text = "Included" + } else if let usedPercentage = snapshot.usedPercentage { + text = QuotaFormatting.formatUsedPercentage(usedPercentage) + } else if let quotaRemaining = snapshot.quotaRemaining { + text = "\(Int(quotaRemaining)) remaining" + } else { + text = "0%" + } + let label = NSTextField(labelWithString: text) label.font = NSFont.systemFont(ofSize: Style.percentageFontSize, weight: .regular) label.translatesAutoresizingMaskIntoConstraints = false label.textColor = .secondaryLabelColor label.alignment = .right - + return label } - + private func addProgressBar(to container: NSView, snapshot: QuotaSnapshot, titleLabel: NSTextField, percentageLabel: NSTextField) { - let usedPercentage = 100.0 - snapshot.percentRemaining - let color = getProgressBarColor(for: usedPercentage) + let usedPercentage = snapshot.usedPercentage ?? 0 + let color = progressBarColor(for: snapshot.usageLevel) let progressBackground = createProgressBackground(color: color) let progressFill = createProgressFill(color: color, usedPercentage: usedPercentage) @@ -315,48 +319,69 @@ extension QuotaView { ]) } - private func getProgressBarColor(for usedPercentage: Float) -> NSColor { - switch usedPercentage { - case 90...: - return .systemRed - case 75..<90: - return .systemYellow - default: - return .systemBlue + private func progressBarColor(for level: QuotaSnapshot.UsageLevel) -> NSColor { + switch level { + case .critical: return .systemRed + case .warning: return .systemYellow + case .healthy: return .systemBlue } } } // MARK: - Footer Section extension QuotaView { + private func createUnlimitedMessageLabel() -> NSTextField { + let label = NSTextField(labelWithString: "You have no monthly limit on AI credits usage set by your organization.") + label.font = NSFont.systemFont(ofSize: Style.footerFontSize, weight: .regular) + label.translatesAutoresizingMaskIntoConstraints = false + label.textColor = .secondaryLabelColor + label.alignment = .left + label.lineBreakMode = .byWordWrapping + label.maximumNumberOfLines = 0 + label.preferredMaxLayoutWidth = Layout.viewWidth - Layout.horizontalMargin * 2 + return label + } + + private func createRefreshTextLabel() -> NSTextField { + let dateString = quotaInfo.resetDateUtc ?? quotaInfo.resetDate + let label = NSTextField(labelWithString: QuotaFormatting.formatResetText(dateString)) + label.font = NSFont.systemFont(ofSize: Style.footerFontSize, weight: .regular) + label.translatesAutoresizingMaskIntoConstraints = false + label.textColor = .secondaryLabelColor + label.alignment = .left + label.lineBreakMode = .byWordWrapping + label.maximumNumberOfLines = 0 + label.preferredMaxLayoutWidth = Layout.viewWidth - Layout.horizontalMargin * 2 + return label + } + private func createStatusMessageLabel() -> NSTextField { - let message = premiumInteractions.overagePermitted ? - "Additional paid premium requests enabled." : - "Additional paid premium requests disabled." - + let overagePermitted = quotaInfo.overagePermitted + let message: String + if tokenBasedBillingEnabled { + message = overagePermitted ? "Additional usage enabled." : "Additional usage not enabled." + } else { + message = overagePermitted ? + "Additional paid premium requests enabled." : + "Additional paid premium requests disabled." + } + let label = NSTextField(labelWithString: isFreeUser ? "" : message) label.font = NSFont.systemFont(ofSize: Style.footerFontSize, weight: .regular) label.translatesAutoresizingMaskIntoConstraints = false - label.textColor = .secondaryLabelColor + label.textColor = (tokenBasedBillingEnabled && overagePermitted) ? .labelColor : .secondaryLabelColor label.alignment = .left return label } - + private func createResetTextLabel() -> NSTextField { - - // Format reset date - let formatter = DateFormatter() - formatter.dateFormat = "yyyy.MM.dd" - - var resetText = "Allowance resets \(resetDate)." - - if let date = formatter.date(from: resetDate) { - let outputFormatter = DateFormatter() - outputFormatter.dateFormat = "MMMM d, yyyy" - let formattedDate = outputFormatter.string(from: date) - resetText = "Allowance resets \(formattedDate)." + let resetText: String + if tokenBasedBillingEnabled { + resetText = QuotaFormatting.formatResetText(quotaInfo.resetDateUtc ?? quotaInfo.resetDate) + } else { + resetText = legacyAllowanceResetText(quotaInfo.resetDate) } - + let label = NSTextField(labelWithString: resetText) label.font = NSFont.systemFont(ofSize: Style.footerFontSize, weight: .regular) label.translatesAutoresizingMaskIntoConstraints = false @@ -364,49 +389,133 @@ extension QuotaView { label.alignment = .left return label } + + private func legacyAllowanceResetText(_ dateString: String) -> String { + for format in ["yyyy-MM-dd", "yyyy.MM.dd"] { + let formatter = DateFormatter() + formatter.dateFormat = format + if let date = formatter.date(from: dateString) { + let outputFormatter = DateFormatter() + outputFormatter.dateFormat = "MMMM d, yyyy" + return "Allowance resets \(outputFormatter.string(from: date))." + } + } + return "Allowance resets \(dateString)." + } - private func createUpsellLabel() -> NSButton { - if isFreeUser { - let button = NSButton() - let upgradeTitle = "Upgrade to Copilot Pro" - - button.translatesAutoresizingMaskIntoConstraints = false - button.bezelStyle = .push - if isFreeQuotaUsedUp { - if #available(macOS 26.0, *) { - button.attributedTitle = NSAttributedString( - string: upgradeTitle, - attributes: [.foregroundColor: NSColor.controlTextColor] - ) - button.bezelColor = .controlBackgroundColor + private func createUpsellView() -> (NSView, CGFloat) { + if tokenBasedBillingEnabled || isFreeUser { + var buttons: [NSButton] = [] + if tokenBasedBillingEnabled, isPaidIndividualUser { + let overagePermitted = quotaInfo.premiumInteractions?.overagePermitted ?? false + let primaryTitle = overagePermitted ? "Increase Budget" : "Enable Additional Usage" + buttons.append(makeProminentButton(title: primaryTitle, action: #selector(openCopilotManageOverage))) + } + if canUpgradePlan { + if isFreeUser, !tokenBasedBillingEnabled { + buttons.append(createUpgradeToProButton()) } else { - button.attributedTitle = NSAttributedString( - string: upgradeTitle, - attributes: [.foregroundColor: NSColor.white] - ) - button.bezelColor = .controlAccentColor + let upgrade = buttons.isEmpty + ? makeProminentButton(title: "Upgrade Plan", action: #selector(openCopilotUpgradePlan)) + : makeBorderedButton(title: "Upgrade Plan", action: #selector(openCopilotUpgradePlan)) + buttons.append(upgrade) } - } else { - button.title = upgradeTitle } - button.controlSize = .large - button.target = self - button.action = #selector(openCopilotUpgradePlan) + switch buttons.count { + case 1: + let height = (isFreeUser && !tokenBasedBillingEnabled) + ? Layout.upgradeButtonHeight + : Layout.compactUpgradeButtonHeight + return (buttons[0], height) + case 2: return (makeButtonStack(buttons: buttons), Layout.dualButtonHeight) + default: + if isFreeUser { return (NSView(), 0) } + break // TBB org/CBCE: fall through to default link + } + } + + let button = HoverButton() + let title = tokenBasedBillingEnabled ? "Manage your Budget" : "Manage paid premium requests" + button.setLinkStyle(title: title, fontSize: Style.footerFontSize) + button.translatesAutoresizingMaskIntoConstraints = false + button.alphaValue = Style.labelAlphaValue + button.alignment = .left + button.target = self + button.action = #selector(openCopilotManageOverage) + return (button, Layout.linkLabelHeight) + } + + private func createUpgradeToProButton() -> NSButton { + let button = NSButton() + let upgradeTitle = "Upgrade to Copilot Pro" - return button + button.translatesAutoresizingMaskIntoConstraints = false + button.bezelStyle = .push + if isFreeQuotaUsedUp { + if #available(macOS 26.0, *) { + button.attributedTitle = NSAttributedString( + string: upgradeTitle, + attributes: [.foregroundColor: NSColor.controlTextColor] + ) + button.bezelColor = .controlBackgroundColor + } else { + button.attributedTitle = NSAttributedString( + string: upgradeTitle, + attributes: [.foregroundColor: NSColor.white] + ) + button.bezelColor = .controlAccentColor + } } else { - let button = HoverButton() - let title = "Manage paid premium requests" - - button.setLinkStyle(title: title, fontSize: Style.footerFontSize) - button.translatesAutoresizingMaskIntoConstraints = false - button.alphaValue = Style.labelAlphaValue - button.alignment = .left - button.target = self - button.action = #selector(openCopilotManageOverage) - - return button + button.title = upgradeTitle } + button.controlSize = .large + button.target = self + button.action = #selector(openCopilotUpgradePlan) + return button + } + + private func makeProminentButton(title: String, action: Selector) -> NSButton { + let button = NSButton() + button.translatesAutoresizingMaskIntoConstraints = false + button.bezelStyle = .push + button.controlSize = .regular + button.isBordered = false + button.wantsLayer = true + button.layer?.backgroundColor = NSColor.controlAccentColor.cgColor + button.layer?.cornerRadius = 6 + button.attributedTitle = NSAttributedString( + string: title, + attributes: [.foregroundColor: NSColor.white] + ) + button.target = self + button.action = action + return button + } + + private func makeBorderedButton(title: String, action: Selector) -> NSButton { + let button = NSButton() + button.translatesAutoresizingMaskIntoConstraints = false + button.bezelStyle = .push + button.controlSize = .regular + button.title = title + button.target = self + button.action = action + return button + } + + private func makeButtonStack(buttons: [NSButton]) -> NSStackView { + let stack = NSStackView(views: buttons) + stack.orientation = .vertical + stack.alignment = .leading + stack.distribution = .fillEqually + stack.spacing = 6 + stack.translatesAutoresizingMaskIntoConstraints = false + for button in buttons { + button.leadingAnchor.constraint(equalTo: stack.leadingAnchor).isActive = true + button.trailingAnchor.constraint(equalTo: stack.trailingAnchor).isActive = true + button.heightAnchor.constraint(equalToConstant: 24).isActive = true + } + return stack } } @@ -419,16 +528,26 @@ extension QuotaView { private func buildConstraints(_ components: ViewComponents) -> [NSLayoutConstraint] { var constraints: [NSLayoutConstraint] = [] - + // Title constraints constraints.append(contentsOf: buildTitleConstraints(components.titleContainer)) - + + if let unlimitedLabel = components.unlimitedMessageLabel { + constraints.append(contentsOf: [ + unlimitedLabel.topAnchor.constraint(equalTo: components.titleContainer.bottomAnchor, constant: Layout.verticalSpacing), + unlimitedLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Layout.horizontalMargin), + unlimitedLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Layout.horizontalMargin), + unlimitedLabel.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + return constraints + } + // Progress view constraints constraints.append(contentsOf: buildProgressViewConstraints(components)) - + // Footer constraints constraints.append(contentsOf: buildFooterConstraints(components)) - + return constraints } @@ -442,109 +561,94 @@ extension QuotaView { } private func buildProgressViewConstraints(_ components: ViewComponents) -> [NSLayoutConstraint] { - let completionsView = components.progressViews[0] - let chatView = components.progressViews[1] - var constraints: [NSLayoutConstraint] = [] - - if !isFreeUser { - let premiumView = components.progressViews[2] - constraints.append(contentsOf: buildPremiumProgressConstraints(premiumView, titleContainer: components.titleContainer)) - constraints.append(contentsOf: buildCompletionsProgressConstraints(completionsView, topView: premiumView, isPremiumUnlimited: premiumInteractions.unlimited)) - } else { - constraints.append(contentsOf: buildCompletionsProgressConstraints(completionsView, topView: components.titleContainer, isPremiumUnlimited: false)) + var previousView: NSView = components.titleContainer + + for progressView in components.progressViews { + constraints.append(contentsOf: [ + progressView.topAnchor.constraint(equalTo: previousView.bottomAnchor, constant: Layout.verticalSpacing), + progressView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Layout.horizontalMargin), + progressView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Layout.horizontalMargin), + progressView.heightAnchor.constraint(equalToConstant: Layout.progressBarHeight) + ]) + previousView = progressView } - - constraints.append(contentsOf: buildChatProgressConstraints(chatView, topView: completionsView)) - + return constraints } - - private func buildPremiumProgressConstraints(_ premiumView: NSView, titleContainer: NSView) -> [NSLayoutConstraint] { - return [ - premiumView.topAnchor.constraint(equalTo: titleContainer.bottomAnchor, constant: Layout.verticalSpacing), - premiumView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Layout.horizontalMargin), - premiumView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Layout.horizontalMargin), - premiumView.heightAnchor.constraint( - equalToConstant: premiumInteractions.unlimited ? Layout.unlimitedProgressBarHeight : Layout.progressBarHeight - ) - ] - } - - private func buildCompletionsProgressConstraints(_ completionsView: NSView, topView: NSView, isPremiumUnlimited: Bool) -> [NSLayoutConstraint] { - let topSpacing = isPremiumUnlimited ? Layout.unlimitedVerticalSpacing : Layout.verticalSpacing - - return [ - completionsView.topAnchor.constraint(equalTo: topView.bottomAnchor, constant: topSpacing), - completionsView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Layout.horizontalMargin), - completionsView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Layout.horizontalMargin), - completionsView.heightAnchor.constraint( - equalToConstant: completions.unlimited ? Layout.unlimitedProgressBarHeight : Layout.progressBarHeight - ) - ] - } - - private func buildChatProgressConstraints(_ chatView: NSView, topView: NSView) -> [NSLayoutConstraint] { - let topSpacing = completions.unlimited ? Layout.unlimitedVerticalSpacing : Layout.verticalSpacing - - return [ - chatView.topAnchor.constraint(equalTo: topView.bottomAnchor, constant: topSpacing), - chatView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Layout.horizontalMargin), - chatView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Layout.horizontalMargin), - chatView.heightAnchor.constraint( - equalToConstant: chat.unlimited ? Layout.unlimitedProgressBarHeight : Layout.progressBarHeight - ) - ] - } - + private func buildFooterConstraints(_ components: ViewComponents) -> [NSLayoutConstraint] { - let chatView = components.progressViews[1] - let topSpacing = chat.unlimited ? Layout.unlimitedVerticalSpacing : Layout.verticalSpacing - + let lastProgressView = components.progressViews.last ?? components.titleContainer + let showResetText = true + var constraints = [NSLayoutConstraint]() - - if !isFreeUser { - // Add status message label constraints + + // CB/CE non-unlimited: show refresh text label + status message (if premium info exists) + if let refreshLabel = components.refreshTextLabel { constraints.append(contentsOf: [ - components.statusMessageLabel.topAnchor.constraint(equalTo: chatView.bottomAnchor, constant: topSpacing), - components.statusMessageLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Layout.horizontalMargin), - components.statusMessageLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Layout.horizontalMargin), - components.statusMessageLabel.heightAnchor.constraint(equalToConstant: Layout.footerTextHeight) + refreshLabel.topAnchor.constraint(equalTo: lastProgressView.bottomAnchor, constant: Layout.smallVerticalSpacing), + refreshLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Layout.horizontalMargin), + refreshLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Layout.horizontalMargin), + refreshLabel.heightAnchor.constraint(equalToConstant: Layout.footerTextHeight) ]) - - // Add reset text label constraints with status message label as the top anchor + var anchor: NSView = refreshLabel + if quotaInfo.premiumInteractions != nil { + let statusHeight = tokenBasedBillingEnabled ? Layout.statusMessageHeight : Layout.footerTextHeight + constraints.append(contentsOf: [ + components.statusMessageLabel.topAnchor.constraint(equalTo: anchor.bottomAnchor, constant: Layout.verticalSpacing), + components.statusMessageLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Layout.horizontalMargin), + components.statusMessageLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Layout.horizontalMargin), + components.statusMessageLabel.heightAnchor.constraint(equalToConstant: statusHeight) + ]) + anchor = components.statusMessageLabel + } + constraints.append(anchor.bottomAnchor.constraint(equalTo: bottomAnchor)) + return constraints + } + + // Anchor for the element after progress views + var lastAnchorView: NSView = lastProgressView + + if showResetText { + let resetTopSpacing = isFreeUser ? Layout.verticalSpacing : Layout.smallVerticalSpacing constraints.append(contentsOf: [ - components.resetTextLabel.topAnchor.constraint(equalTo: components.statusMessageLabel.bottomAnchor), + components.resetTextLabel.topAnchor.constraint(equalTo: lastProgressView.bottomAnchor, constant: resetTopSpacing), components.resetTextLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Layout.horizontalMargin), components.resetTextLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Layout.horizontalMargin), components.resetTextLabel.heightAnchor.constraint(equalToConstant: Layout.footerTextHeight) ]) - } else { - // For free users, only show reset text label + lastAnchorView = components.resetTextLabel + } + + if !isFreeUser, quotaInfo.premiumInteractions != nil || isPaidIndividualUser { + let statusHeight = tokenBasedBillingEnabled ? Layout.statusMessageHeight : Layout.footerTextHeight constraints.append(contentsOf: [ - components.resetTextLabel.topAnchor.constraint(equalTo: chatView.bottomAnchor, constant: topSpacing), - components.resetTextLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Layout.horizontalMargin), - components.resetTextLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Layout.horizontalMargin), - components.resetTextLabel.heightAnchor.constraint(equalToConstant: Layout.footerTextHeight) + components.statusMessageLabel.topAnchor.constraint(equalTo: lastAnchorView.bottomAnchor, constant: Layout.verticalSpacing), + components.statusMessageLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Layout.horizontalMargin), + components.statusMessageLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Layout.horizontalMargin), + components.statusMessageLabel.heightAnchor.constraint(equalToConstant: statusHeight) ]) + lastAnchorView = components.statusMessageLabel } - - if isOrgUser || (isFreeUser && isFreeQuotaRemaining) { - // Do not show link label for business or enterprise users - constraints.append(components.resetTextLabel.bottomAnchor.constraint(equalTo: bottomAnchor)) + + if isCBCE || (isFreeUser && isFreeQuotaRemaining) { + constraints.append(lastAnchorView.bottomAnchor.constraint(equalTo: bottomAnchor)) return constraints } - + // Add link label constraints + let isTallButton = components.upsellHeight == Layout.upgradeButtonHeight || components.upsellHeight == Layout.compactUpgradeButtonHeight + let upsellTopSpacing: CGFloat = isTallButton ? Layout.smallVerticalSpacing : 0 + let upsellBottomSpacing: CGFloat = isTallButton ? -Layout.smallVerticalSpacing : 0 constraints.append(contentsOf: [ - components.upsellLabel.topAnchor.constraint(equalTo: components.resetTextLabel.bottomAnchor), - components.upsellLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Layout.horizontalMargin), - components.upsellLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Layout.horizontalMargin), - components.upsellLabel.heightAnchor.constraint(equalToConstant: isFreeUser ? Layout.upgradeButtonHeight : Layout.linkLabelHeight), - - components.upsellLabel.bottomAnchor.constraint(equalTo: bottomAnchor) + components.upsellView.topAnchor.constraint(equalTo: lastAnchorView.bottomAnchor, constant: upsellTopSpacing), + components.upsellView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Layout.horizontalMargin), + components.upsellView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Layout.horizontalMargin), + components.upsellView.heightAnchor.constraint(equalToConstant: components.upsellHeight), + + components.upsellView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: upsellBottomSpacing) ]) - + return constraints } } @@ -552,26 +656,20 @@ extension QuotaView { // MARK: - Actions extension QuotaView { @objc private func openCopilotSettings() { - Task { - if let url = URL(string: "https://aka.ms/github-copilot-settings") { - NSWorkspace.shared.open(url) - } - } + openURL(QuotaFormatting.settingsURL) } - + @objc private func openCopilotManageOverage() { - Task { - if let url = URL(string: "https://aka.ms/github-copilot-manage-overage") { - NSWorkspace.shared.open(url) - } - } + openURL(QuotaFormatting.manageOverageURL) } - + @objc private func openCopilotUpgradePlan() { - Task { - if let url = URL(string: "https://aka.ms/github-copilot-upgrade-plan") { - NSWorkspace.shared.open(url) - } + openURL(QuotaFormatting.upgradePlanURL) + } + + private func openURL(_ urlString: String) { + if let url = URL(string: urlString) { + NSWorkspace.shared.open(url) } } } @@ -581,24 +679,30 @@ private struct ViewComponents { let titleContainer: NSView let progressViews: [NSView] let statusMessageLabel: NSTextField + let unlimitedMessageLabel: NSTextField? + let refreshTextLabel: NSTextField? let resetTextLabel: NSTextField - let upsellLabel: NSButton + let upsellView: NSView + let upsellHeight: CGFloat } // MARK: - Layout Constants private struct Layout { static let viewWidth: CGFloat = 256 static let horizontalMargin: CGFloat = 14 - static let verticalSpacing: CGFloat = 8 + static let verticalSpacing: CGFloat = 6 static let unlimitedVerticalSpacing: CGFloat = 6 - static let smallVerticalSpacing: CGFloat = 4 + static let smallVerticalSpacing: CGFloat = 2 static let titleHeight: CGFloat = 20 static let progressBarHeight: CGFloat = 22 static let unlimitedProgressBarHeight: CGFloat = 16 static let footerTextHeight: CGFloat = 16 + static let statusMessageHeight: CGFloat = 20 static let linkLabelHeight: CGFloat = 16 static let upgradeButtonHeight: CGFloat = 40 + static let compactUpgradeButtonHeight: CGFloat = 28 + static let dualButtonHeight: CGFloat = 54 static let settingsButtonSize: CGFloat = 20 static let settingsButtonHoverSize: CGFloat = 14 diff --git a/Tool/Sources/TelemetryService/TelemetryCleaner.swift b/Tool/Sources/TelemetryService/TelemetryCleaner.swift index 069ad843..5dcd6987 100644 --- a/Tool/Sources/TelemetryService/TelemetryCleaner.swift +++ b/Tool/Sources/TelemetryService/TelemetryCleaner.swift @@ -69,7 +69,7 @@ public struct TelemetryCleaner { ("Email", "@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-]+") ] - var cleanedValue = value + let cleanedValue = value for (label, pattern) in patterns { if let regex = try? NSRegularExpression(pattern: pattern) { if regex.firstMatch( 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/Workspace.swift b/Tool/Sources/Workspace/Workspace.swift index a179bc83..9d262102 100644 --- a/Tool/Sources/Workspace/Workspace.swift +++ b/Tool/Sources/Workspace/Workspace.swift @@ -260,7 +260,6 @@ extension Workspace { // Handle empty old content (new file) if oldContent.isEmpty { - let endPosition = calculateEndPosition(content: oldContent) return [TextDocumentContentChangeEvent( range: LSPRange( start: Position(line: 0, character: 0), diff --git a/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift b/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift index 03656855..d99a744f 100644 --- a/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift +++ b/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift @@ -153,7 +153,7 @@ public extension Filespace { /// - Returns: `true` if the nes suggestion is still valid @WorkspaceActor func validateNESSuggestions(lines: [String], cursorPosition: CursorPosition) -> Bool { - guard let presentingNESSuggestion else { return false } + guard presentingNESSuggestion != nil else { return false } let updatedSnapshot = FilespaceSuggestionSnapshot(lines: lines, cursorPosition: cursorPosition) diff --git a/Tool/Sources/XPCShared/XPCCommunicationBridge.swift b/Tool/Sources/XPCShared/XPCCommunicationBridge.swift index 4b7d09cb..d54976d4 100644 --- a/Tool/Sources/XPCShared/XPCCommunicationBridge.swift +++ b/Tool/Sources/XPCShared/XPCCommunicationBridge.swift @@ -80,7 +80,6 @@ extension XPCCommunicationBridge { } } -@available(macOS 13.0, *) public func showBackgroundPermissionAlert() { let alert = NSAlert() alert.messageText = "Background Permission Required" diff --git a/Tool/Sources/XcodeInspector/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/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)"