diff --git a/.agent-plugin/.acp.json b/.agent-plugin/.acp.json
new file mode 100644
index 00000000..4284e5c0
--- /dev/null
+++ b/.agent-plugin/.acp.json
@@ -0,0 +1,20 @@
+{
+ "github-copilot": {
+ "distribution": {
+ "binary": {
+ "darwin-aarch64": {
+ "archive": "https://github.com/github/copilot-cli/releases/download/v1.0.78/copilot-darwin-arm64.tar.gz",
+ "cmd": "./copilot",
+ "args": ["--acp"],
+ "env": {}
+ }
+ }
+ },
+ "extendedFeatures": {
+ "userConfigFile": {
+ "path": "~/.copilot/settings.json",
+ "description": "GitHub Copilot configuration and authentication credentials. Populated automatically by `copilot login`; set `store_token_plaintext: true` and add a token under `copilot_tokens` to supply credentials without an interactive session."
+ }
+ }
+ }
+}
diff --git a/.agent-plugin/icon.svg b/.agent-plugin/icon.svg
new file mode 100644
index 00000000..0b596674
--- /dev/null
+++ b/.agent-plugin/icon.svg
@@ -0,0 +1,5 @@
+
diff --git a/.agent-plugin/plugin.json b/.agent-plugin/plugin.json
new file mode 100644
index 00000000..1785e69a
--- /dev/null
+++ b/.agent-plugin/plugin.json
@@ -0,0 +1,17 @@
+{
+ "name": "GitHub Copilot",
+ "slug": "github-copilot",
+ "description": "GitHub's AI pair programmer",
+ "version": "0.1.0",
+ "author": {
+ "name": "GitHub",
+ "url": "https://github.com/github"
+ },
+ "repository": "https://github.com/github/CopilotForXcode",
+ "homepage": "https://github.com/features/copilot/",
+ "license": "proprietary",
+ "icon": "https://raw.githubusercontent.com/github/CopilotForXcode/main/.agent-plugin/icon.svg",
+ "tags": ["copilot", "github", "acp", "ai-assistant"],
+ "category": "ai-agent",
+ "platforms": ["darwin-aarch64"]
+}
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
index 434de549..8928b689 100644
--- a/.github/ISSUE_TEMPLATE/config.yml
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -1,5 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Questions
- url: https://github.com/orgs/community/discussions/categories/copilot
+ url: https://github.com/github/CopilotForXcode/discussions
about: Please ask and answer questions about GitHub Copilot here
diff --git a/.github/workflows/auto-close-pr.yml b/.github/workflows/auto-close-pr.yml
index de2ca780..90beda84 100644
--- a/.github/workflows/auto-close-pr.yml
+++ b/.github/workflows/auto-close-pr.yml
@@ -14,7 +14,8 @@ jobs:
gh pr close ${{ github.event.pull_request.number }} --comment \
"At the moment we are not accepting contributions to the repository.
- Feedback for GitHub Copilot for Xcode can be given in the [Copilot community discussions](https://github.com/orgs/community/discussions/categories/copilot)."
+ Feedback for GitHub Copilot for Xcode can be given in the [Copilot community discussions](https://github.com/github/CopilotForXcode/discussions)."
+ if: ${{ !(startsWith(github.head_ref, 'release/') && github.event.pull_request.head.repo.full_name == github.repository) }}
env:
GH_REPO: ${{ github.repository }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 78e35963..9c414bc1 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -27,6 +27,10 @@ jobs:
fail-fast: false
matrix:
include:
+ - language: actions
+ build-mode: none
+ - language: javascript-typescript
+ build-mode: none
- language: python
build-mode: none
- language: swift
@@ -37,7 +41,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
- uses: github/codeql-action/init@v3
+ uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
@@ -66,6 +70,6 @@ jobs:
CODE_SIGNING_ALLOWED="NO"
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@v3
+ uses: github/codeql-action/analyze@v4
with:
category: "/language:${{matrix.language}}"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 765d3400..42463f66 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,88 @@ 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.
+
+### Changed
+- Refine built-in tools layout and displaying error and output details.
+- Better support toolCallingLoop continue operation for subagent turn.
+- Update feedback forum link.
+- Update client-side MCP restore and persist.
+- Adopt NES notification.
+
+### Fixed
+- Disable auto focus for fix error window.
+- Fixed an issue where no file change was made when insert_edit_into_file tool succeeds.
+- Fixed an issue where insert edit was applied to the incorrect file.
+- Fixed model picker to use model id instead of model family.
+- Fixed read_file, read_directory tool randomly failing.
+
+## 0.45.0 - November 14, 2025
+### Added
+- New models: GPT-5.1, GPT-5.1-Codex, GPT-5.1-Codex-Mini, Claude Haiku 4.5, and Auto (preview).
+- Added support for custom agents (preview).
+- Introduced the built-in Plan agent (preview).
+- Added support for subagent execution (preview).
+- Added support for Next Edit Suggestions (preview).
+
+### Changed
+- MCP servers now support dynamic OAuth setup for third-party authentication providers.
+- Added a setting to configure the maximum number of tool requests allowed.
+
+### Fixed
+- Fixed an issue that the terminal view in Agent conversation was clipped
+- Fixed an issue that the Chat panel failed to recognize newly created workspaces.
+
## 0.44.0 - October 15, 2025
### Added
- Added support for new models in Chat: Grok Code Fast 1, Claude Sonnet 4.5, Claude Opus 4, Claude Opus 4.1 and GPT-5 mini.
diff --git a/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/Config.debug.xcconfig b/Config.debug.xcconfig
index 63fae668..da143524 100644
--- a/Config.debug.xcconfig
+++ b/Config.debug.xcconfig
@@ -10,7 +10,7 @@ EXTENSION_BUNDLE_NAME = GitHub Copilot Dev
EXTENSION_BUNDLE_DISPLAY_NAME = GitHub Copilot Dev
EXTENSION_SERVICE_NAME = GitHub Copilot for Xcode Extension
COPILOT_DOCS_URL = https:$(SLASH)$(SLASH)docs.github.com/en/copilot
-COPILOT_FORUM_URL = https:$(SLASH)$(SLASH)github.com/orgs/community/discussions/categories/copilot
+COPILOT_FORUM_URL = https:$(SLASH)$(SLASH)github.com/github/CopilotForXcode/discussions
// see also target Configs
diff --git a/Config.xcconfig b/Config.xcconfig
index 5fba3479..eef78ad4 100644
--- a/Config.xcconfig
+++ b/Config.xcconfig
@@ -10,6 +10,6 @@ EXTENSION_BUNDLE_NAME = GitHub Copilot
EXTENSION_BUNDLE_DISPLAY_NAME = GitHub Copilot
EXTENSION_SERVICE_NAME = GitHub Copilot for Xcode Extension
COPILOT_DOCS_URL = https:$(SLASH)$(SLASH)docs.github.com/en/copilot
-COPILOT_FORUM_URL = https:$(SLASH)$(SLASH)github.com/orgs/community/discussions/categories/copilot
+COPILOT_FORUM_URL = https:$(SLASH)$(SLASH)github.com/github/CopilotForXcode/discussions
// see also target Configs
diff --git a/Copilot for Xcode.xcodeproj/project.pbxproj b/Copilot for Xcode.xcodeproj/project.pbxproj
index 2cd753c1..d232491a 100644
--- a/Copilot for Xcode.xcodeproj/project.pbxproj
+++ b/Copilot for Xcode.xcodeproj/project.pbxproj
@@ -18,6 +18,8 @@
5EC511E42C90CE9800632BAB /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C8189B1D2938973000C9DCDA /* Assets.xcassets */; };
5EC511E52C90CFD600632BAB /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C861E6142994F6080056CB02 /* Assets.xcassets */; };
5EC511E62C90CFD700632BAB /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C861E6142994F6080056CB02 /* Assets.xcassets */; };
+ 7E6CEC912EAB6774005F2076 /* RejectNESSuggestionCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E6CEC902EAB6774005F2076 /* RejectNESSuggestionCommand.swift */; };
+ 7E856FF72E9F6D24005751CB /* AcceptNESSuggestionCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E856FF62E9F6D1D005751CB /* AcceptNESSuggestionCommand.swift */; };
C8009BFF2941C551007AA7E8 /* ToggleRealtimeSuggestionsCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8009BFE2941C551007AA7E8 /* ToggleRealtimeSuggestionsCommand.swift */; };
C8009C032941C576007AA7E8 /* SyncTextSettingsCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8009C022941C576007AA7E8 /* SyncTextSettingsCommand.swift */; };
C800DBB1294C624D00B04CAC /* PrefetchSuggestionsCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C800DBB0294C624D00B04CAC /* PrefetchSuggestionsCommand.swift */; };
@@ -193,6 +195,8 @@
3E5DB74F2D6B88EE00418952 /* ReleaseNotes.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = ReleaseNotes.md; sourceTree = ""; };
424ACA202CA4697200FA20F2 /* Credits.rtf */ = {isa = PBXFileReference; lastKnownFileType = text.rtf; path = Credits.rtf; sourceTree = ""; };
427C63272C6E868B000E557C /* OpenSettingsCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenSettingsCommand.swift; sourceTree = ""; };
+ 7E6CEC902EAB6774005F2076 /* RejectNESSuggestionCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RejectNESSuggestionCommand.swift; sourceTree = ""; };
+ 7E856FF62E9F6D1D005751CB /* AcceptNESSuggestionCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AcceptNESSuggestionCommand.swift; sourceTree = ""; };
C8009BFE2941C551007AA7E8 /* ToggleRealtimeSuggestionsCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleRealtimeSuggestionsCommand.swift; sourceTree = ""; };
C8009C022941C576007AA7E8 /* SyncTextSettingsCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncTextSettingsCommand.swift; sourceTree = ""; };
C800DBB0294C624D00B04CAC /* PrefetchSuggestionsCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrefetchSuggestionsCommand.swift; sourceTree = ""; };
@@ -330,8 +334,10 @@
C8520300293C4D9000460097 /* Helpers.swift */,
C81458952939EFDC00135263 /* GetSuggestionsCommand.swift */,
C87B03A4293B261200C77EAE /* AcceptSuggestionCommand.swift */,
+ 7E856FF62E9F6D1D005751CB /* AcceptNESSuggestionCommand.swift */,
C80FFB952A95F58200704A25 /* AcceptPromptToCodeCommand.swift */,
C87B03A6293B261900C77EAE /* RejectSuggestionCommand.swift */,
+ 7E6CEC902EAB6774005F2076 /* RejectNESSuggestionCommand.swift */,
C87B03A8293B262600C77EAE /* NextSuggestionCommand.swift */,
C87B03AA293B262E00C77EAE /* PreviousSuggestionCommand.swift */,
C8009BFE2941C551007AA7E8 /* ToggleRealtimeSuggestionsCommand.swift */,
@@ -734,12 +740,14 @@
C8758E7029F04BFF00D29C1C /* CustomCommand.swift in Sources */,
C8758E7229F04CF100D29C1C /* SeparatorCommand.swift in Sources */,
C861A6A329E5503F005C41A3 /* PromptToCodeCommand.swift in Sources */,
+ 7E6CEC912EAB6774005F2076 /* RejectNESSuggestionCommand.swift in Sources */,
C8520301293C4D9000460097 /* Helpers.swift in Sources */,
C8009BFF2941C551007AA7E8 /* ToggleRealtimeSuggestionsCommand.swift in Sources */,
C80FFB962A95F58200704A25 /* AcceptPromptToCodeCommand.swift in Sources */,
427C63282C6E868B000E557C /* OpenSettingsCommand.swift in Sources */,
C87B03A5293B261200C77EAE /* AcceptSuggestionCommand.swift in Sources */,
C87B03A9293B262600C77EAE /* NextSuggestionCommand.swift in Sources */,
+ 7E856FF72E9F6D24005751CB /* AcceptNESSuggestionCommand.swift in Sources */,
C87B03AB293B262E00C77EAE /* PreviousSuggestionCommand.swift in Sources */,
C87B03A7293B261900C77EAE /* RejectSuggestionCommand.swift in Sources */,
C8009C032941C576007AA7E8 /* SyncTextSettingsCommand.swift in Sources */,
@@ -837,7 +845,7 @@
"@executable_path/../Frameworks",
"@executable_path/../../../../Frameworks",
);
- MACOSX_DEPLOYMENT_TARGET = 12.0;
+ MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = "$(APP_VERSION)";
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).EditorExtension";
PRODUCT_NAME = Copilot;
@@ -866,7 +874,7 @@
"@executable_path/../Frameworks",
"@executable_path/../../../../Frameworks",
);
- MACOSX_DEPLOYMENT_TARGET = 12.0;
+ MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = "$(APP_VERSION)";
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).EditorExtension";
PRODUCT_NAME = Copilot;
@@ -928,7 +936,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- MACOSX_DEPLOYMENT_TARGET = 12.0;
+ MACOSX_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
@@ -983,7 +991,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- MACOSX_DEPLOYMENT_TARGET = 12.0;
+ MACOSX_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = macosx;
@@ -1014,7 +1022,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
- MACOSX_DEPLOYMENT_TARGET = 12.0;
+ MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = "$(APP_VERSION)";
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE)";
PRODUCT_MODULE_NAME = Copilot_for_Xcode;
@@ -1048,7 +1056,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
- MACOSX_DEPLOYMENT_TARGET = 12.0;
+ MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = "$(APP_VERSION)";
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE)";
PRODUCT_NAME = "$(HOST_APP_NAME)";
@@ -1064,7 +1072,7 @@
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = VEKTX9H2N7;
ENABLE_HARDENED_RUNTIME = YES;
- MACOSX_DEPLOYMENT_TARGET = 12.0;
+ MACOSX_DEPLOYMENT_TARGET = 13.0;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_VERSION = 5.0;
@@ -1079,7 +1087,7 @@
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=macosx*]" = VEKTX9H2N7;
ENABLE_HARDENED_RUNTIME = YES;
- MACOSX_DEPLOYMENT_TARGET = 12.0;
+ MACOSX_DEPLOYMENT_TARGET = 13.0;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES;
@@ -1109,7 +1117,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
- MACOSX_DEPLOYMENT_TARGET = 12.0;
+ MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = "$(APP_VERSION)";
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).ExtensionService";
PRODUCT_NAME = "$(EXTENSION_SERVICE_NAME)";
@@ -1143,7 +1151,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
- MACOSX_DEPLOYMENT_TARGET = 12.0;
+ MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = "$(APP_VERSION)";
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).ExtensionService";
PRODUCT_NAME = "$(EXTENSION_SERVICE_NAME)";
@@ -1164,7 +1172,7 @@
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
- MACOSX_DEPLOYMENT_TARGET = 12.0;
+ MACOSX_DEPLOYMENT_TARGET = 13.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).CommunicationBridge";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -1185,7 +1193,7 @@
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
- MACOSX_DEPLOYMENT_TARGET = 12.0;
+ MACOSX_DEPLOYMENT_TARGET = 13.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).CommunicationBridge";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
diff --git a/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved
index 3db257ec..064955a6 100644
--- a/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved
+++ b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -279,6 +279,15 @@
"version" : "510.0.3"
}
},
+ {
+ "identity" : "swift-tree-sitter",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/tree-sitter/swift-tree-sitter.git",
+ "state" : {
+ "revision" : "08ef81eb8620617b55b08868126707ad72bf754f",
+ "version" : "0.25.0"
+ }
+ },
{
"identity" : "swiftsoup",
"kind" : "remoteSourceControl",
@@ -306,6 +315,24 @@
"version" : "1.4.0"
}
},
+ {
+ "identity" : "tree-sitter",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/tree-sitter/tree-sitter",
+ "state" : {
+ "revision" : "da6fe9beb4f7f67beb75914ca8e0d48ae48d6406",
+ "version" : "0.25.10"
+ }
+ },
+ {
+ "identity" : "tree-sitter-bash",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/tree-sitter/tree-sitter-bash",
+ "state" : {
+ "revision" : "a06c2e4415e9bc0346c6b86d401879ffb44058f7",
+ "version" : "0.25.1"
+ }
+ },
{
"identity" : "xctest-dynamic-overlay",
"kind" : "remoteSourceControl",
diff --git a/Copilot for Xcode/App.swift b/Copilot for Xcode/App.swift
index c2efb015..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,
@@ -213,6 +235,17 @@ struct CopilotForXcodeApp: App {
hostAppStore.send(.setActiveTab(.byok))
}
}
+
+ DistributedNotificationCenter.default().addObserver(
+ forName: .openAdvancedSettingsWindowRequest,
+ object: nil,
+ queue: .main
+ ) { _ in
+ DispatchQueue.main.async {
+ activateAndOpenSettings()
+ hostAppStore.send(.setActiveTab(.advanced))
+ }
+ }
}
var body: some Scene {
@@ -236,10 +269,8 @@ func activateAndOpenSettings() {
if #available(macOS 14.0, *) {
let environment = SettingsEnvironment()
environment.open()
- } else if #available(macOS 13.0, *) {
- NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
} else {
- NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil)
+ NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
}
}
diff --git a/Copilot for Xcode/Credits.rtf b/Copilot for Xcode/Credits.rtf
index 13a16781..941fbb70 100644
--- a/Copilot for Xcode/Credits.rtf
+++ b/Copilot for Xcode/Credits.rtf
@@ -3349,4 +3349,90 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\
SOFTWARE.\
\
\
+Dependency: https://github.com/tree-sitter/tree-sitter\
+Version: 0.25.10\
+License Content:\
+The MIT License\
+\
+Copyright (c) 2018 Max Brunsfeld
+\
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+\
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+\
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+\
+\
+Dependency: https://github.com/tree-sitter/swift-tree-sitter\
+Version: 0.25.0\
+License Content:\
+BSD 3-Clause License\
+\
+Copyright (c) 2021, Chime
+All rights reserved.
+\
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+\
+1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+\
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+\
+3. Neither the name of the copyright holder nor the names of its
+ contributors may be used to endorse or promote products derived from
+ this software without specific prior written permission.
+\
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+\
+\
+Dependency: https://github.com/tree-sitter/tree-sitter-bash\
+Version: 0.25.1\
+License Content:\
+The MIT License\
+\
+Copyright (c) 2017 Max Brunsfeld
+\
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+\
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+\
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+\
+\
}
\ No newline at end of file
diff --git a/Core/Package.swift b/Core/Package.swift
index 33ad1c48..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",
@@ -93,6 +96,7 @@ let package = Package(
.product(name: "ChatAPIService", package: "Tool"),
.product(name: "Preferences", package: "Tool"),
.product(name: "AXHelper", package: "Tool"),
+ .product(name: "WorkspaceSuggestionService", package: "Tool"),
.product(name: "AsyncAlgorithms", package: "swift-async-algorithms"),
.product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
.product(name: "Dependencies", package: "swift-dependencies"),
@@ -120,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"),
@@ -131,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
@@ -184,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",
@@ -195,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"),
@@ -213,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"),
@@ -253,9 +265,11 @@ let package = Package(
.target(
name: "GitHubCopilotViewModel",
dependencies: [
+ "Client",
.product(name: "GitHubCopilotService", package: "Tool"),
.product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
.product(name: "Status", package: "Tool"),
+ .product(name: "Logger", package: "Tool"),
]
),
@@ -264,6 +278,7 @@ let package = Package(
.target(
name: "KeyBindingManager",
dependencies: [
+ "SuggestionWidget",
.product(name: "Workspace", package: "Tool"),
.product(name: "Preferences", package: "Tool"),
.product(name: "Logger", package: "Tool"),
diff --git a/Core/Sources/ChatService/ChatService.swift b/Core/Sources/ChatService/ChatService.swift
index 3f65d49b..e90faa3c 100644
--- a/Core/Sources/ChatService/ChatService.swift
+++ b/Core/Sources/ChatService/ChatService.swift
@@ -30,7 +30,9 @@ public protocol ChatServiceType {
references: [ConversationAttachedReference],
model: String?,
modelProviderName: String?,
+ reasoningEffort: String?,
agentMode: Bool,
+ customChatModeId: String?,
userLanguage: String?,
turnId: String?
) async throws
@@ -48,16 +50,29 @@ struct ToolCallRequest {
let completion: (AnyJSONRPCResponse) -> Void
}
+struct ConversationTurnTrackingState {
+ var turnParentMap: [String: String] = [:] // Maps subturn ID to parent turn ID
+ var validConversationIds: Set = [] // Tracks all valid conversation IDs including subagents
+
+ mutating func reset() {
+ turnParentMap.removeAll()
+ validConversationIds.removeAll()
+ }
+}
+
public final class ChatService: ChatServiceType, ObservableObject {
public var memory: ContextAwareAutoManagedChatMemory
@Published public internal(set) var chatHistory: [ChatMessage] = []
@Published public internal(set) var isReceivingMessage = false
+ @Published public internal(set) var isSummarizingConversation = false
@Published public internal(set) var fileEditMap: OrderedDictionary = [:]
+ @Published public internal(set) var contextSizeInfo: ContextSizeInfo? = nil
public internal(set) var requestType: RequestType? = nil
public private(set) var chatTabInfo: ChatTabInfo
private let conversationProvider: ConversationServiceProvider?
private let conversationProgressHandler: ConversationProgressHandler
+ private let compressionHandler: CompressionHandler
private let conversationContextHandler: ConversationContextHandler = ConversationContextHandlerImpl.shared
// sync all the files in the workspace to watch for changes.
private let watchedFilesHandler: WatchedFilesHandler = WatchedFilesHandlerImpl.shared
@@ -68,13 +83,27 @@ public final class ChatService: ChatServiceType, ObservableObject {
private var lastUserRequest: ConversationRequest?
private var isRestored: Bool = false
private var pendingToolCallRequests: [String: ToolCallRequest] = [:]
+ // Workaround: toolConfirmation request does not have parent turnId
+ private var conversationTurnTracking = ConversationTurnTrackingState()
+
+ /// 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
@@ -120,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() {
@@ -135,28 +177,19 @@ public final class ChatService: ChatServiceType, ObservableObject {
private func subscribeToClientToolConfirmationEvent() {
ClientToolHandlerImpl.shared.onClientToolConfirmationEvent.sink(receiveValue: { [weak self] (request, completion) in
- guard let params = request.params, params.conversationId == self?.conversationId else { return }
- let editAgentRounds: [AgentRound] = [
- AgentRound(roundId: params.roundId,
- reply: "",
- toolCalls: [
- AgentToolCall(id: params.toolCallId, name: params.name, status: .waitForConfirmation, invokeParams: params)
- ]
- )
- ]
- self?.appendToolCallHistory(turnId: params.turnId, editAgentRounds: editAgentRounds)
- self?.pendingToolCallRequests[params.toolCallId] = ToolCallRequest(
- requestId: request.id,
- turnId: params.turnId,
- roundId: params.roundId,
- toolCallId: params.toolCallId,
- completion: completion)
+ self?.handleClientToolConfirmationEvent(request: request, completion: completion)
}).store(in: &cancellables)
}
private func subscribeToClientToolInvokeEvent() {
ClientToolHandlerImpl.shared.onClientToolInvokeEvent.sink(receiveValue: { [weak self] (request, completion) in
- guard let params = request.params, params.conversationId == self?.conversationId else { return }
+ guard let params = request.params else { return }
+
+ // Check if this conversationId is valid (main conversation or subagent conversation)
+ guard let validIds = self?.conversationTurnTracking.validConversationIds, validIds.contains(params.conversationId) else {
+ return
+ }
+
guard let copilotTool = CopilotToolRegistry.shared.getTool(name: params.name) else {
completion(AnyJSONRPCResponse(id: request.id,
result: JSONValue.array([
@@ -172,11 +205,11 @@ public final class ChatService: ChatServiceType, ObservableObject {
return
}
- copilotTool.invokeTool(request, completion: completion, contextProvider: self)
+ _ = copilotTool.invokeTool(request, completion: completion, contextProvider: self)
}).store(in: &cancellables)
}
- func appendToolCallHistory(turnId: String, editAgentRounds: [AgentRound], fileEdits: [FileEdit] = []) {
+ func appendToolCallHistory(turnId: String, editAgentRounds: [AgentRound], fileEdits: [FileEdit] = [], parentTurnId: String? = nil) {
let chatTabId = self.chatTabInfo.id
Task {
let turnStatus: ChatMessage.TurnStatus? = {
@@ -195,6 +228,7 @@ public final class ChatService: ChatServiceType, ObservableObject {
assistantMessageWithId: turnId,
chatTabID: chatTabId,
editAgentRounds: editAgentRounds,
+ parentTurnId: parentTurnId,
fileEdits: fileEdits,
turnStatus: turnStatus
)
@@ -217,86 +251,99 @@ 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
}
+ /// Updates the status of a tool call (accepted, cancelled, etc.) and notifies the server
+ ///
+ /// This method handles two key responsibilities:
+ /// 1. Sends confirmation response back to the server when user accepts/cancels
+ /// 2. Updates the tool call status in chat history UI (including subagent tool calls)
public func updateToolCallStatus(toolCallId: String, status: AgentToolCall.ToolCallStatus, payload: Any? = nil) {
- // Send the tool call result back to the server
- if let toolCallRequest = self.pendingToolCallRequests[toolCallId], status == .accepted || status == .cancelled {
+ // Capture the pending request info before removing it from the dictionary
+ let toolCallRequest = self.pendingToolCallRequests[toolCallId]
+
+ // Step 1: Send confirmation response to server (for accept/cancel actions only)
+ if let toolCallRequest = toolCallRequest, status == .accepted || status == .cancelled {
self.pendingToolCallRequests.removeValue(forKey: toolCallId)
- let toolResult = LanguageModelToolConfirmationResult(
- result: status == .accepted ? .Accept : .Dismiss
- )
- let jsonResult = try? JSONEncoder().encode(toolResult)
- let jsonValue = (try? JSONDecoder().decode(JSONValue.self, from: jsonResult ?? Data())) ?? JSONValue.null
- toolCallRequest.completion(
- AnyJSONRPCResponse(
- id: toolCallRequest.requestId,
- result: JSONValue.array([
- jsonValue,
- JSONValue.null
- ])
- )
- )
+ sendToolConfirmationResponse(toolCallRequest, accepted: status == .accepted)
}
- // Update the tool call status in the chat history
+ // Step 2: Update the tool call status in chat history UI
Task {
- guard let lastMessage = await memory.history.last, lastMessage.role == .assistant else {
+ guard let targetMessage = await ToolCallStatusUpdater.findMessageContainingToolCall(
+ toolCallRequest,
+ conversationTurnTracking: conversationTurnTracking,
+ history: await memory.history
+ ) else {
return
}
-
- var updatedAgentRounds: [AgentRound] = []
- for i in 0.. Bool {
+ conversationTurnTracking.validConversationIds.contains(conversationId)
+ }
+
+ /// Workaround: toolConfirmation request does not have parent turnId.
+ func parentTurnIdForTurnId(_ turnId: String) -> String? {
+ conversationTurnTracking.turnParentMap[turnId]
+ }
+
+ func storePendingToolCallRequest(toolCallId: String, request: ToolCallRequest) {
+ pendingToolCallRequests[toolCallId] = request
+ }
+
+ /// Sends the confirmation response (accept/dismiss) back to the server
+ func sendToolConfirmationResponse(_ request: ToolCallRequest, accepted: Bool) {
+ let toolResult = LanguageModelToolConfirmationResult(
+ result: accepted ? .Accept : .Dismiss
+ )
+ let jsonResult = try? JSONEncoder().encode(toolResult)
+ let jsonValue = (try? JSONDecoder().decode(JSONValue.self, from: jsonResult ?? Data())) ?? JSONValue.null
+
+ request.completion(
+ AnyJSONRPCResponse(
+ id: request.requestId,
+ result: JSONValue.array([jsonValue, JSONValue.null])
+ )
+ )
+ }
+
public enum ChatServiceError: Error, LocalizedError {
case conflictingImageFormats(String)
@@ -317,7 +364,9 @@ 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,
turnId: String? = nil
) async throws {
@@ -422,7 +471,9 @@ public final class ChatService: ChatServiceType, ObservableObject {
references: references,
model: model,
modelProviderName: modelProviderName,
+ reasoningEffort: reasoningEffort,
agentMode: agentMode,
+ customChatModeId: customChatModeId,
userLanguage: userLanguage,
turnId: currentTurnId,
skillSet: validSkillSet
@@ -430,7 +481,18 @@ public final class ChatService: ChatServiceType, ObservableObject {
self.lastUserRequest = request
self.skillSet = validSkillSet
- try await sendConversationRequest(request)
+
+ do {
+ if let response = try await sendConversationRequest(request) {
+ await handleConversationCreateResponse(response)
+ }
+ } catch {
+ // Check if this is a certificate error and show helpful message
+ if isCertificateError(error) {
+ await showCertificateErrorMessage(turnId: currentTurnId)
+ }
+ throw error
+ }
}
private func createConversationRequest(
@@ -441,7 +503,9 @@ 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,
turnId: String? = nil,
skillSet: [ConversationSkill]
@@ -466,11 +530,27 @@ public final class ChatService: ChatServiceType, ObservableObject {
references: references,
model: model,
modelProviderName: modelProviderName,
+ reasoningEffort: reasoningEffort,
agentMode: agentMode,
+ customChatModeId: customChatModeId,
userLanguage: userLanguage,
turnId: turnId
)
}
+
+ private func handleConversationCreateResponse(_ response: ConversationCreateResponse) async {
+ await memory.mutateHistory { history in
+ if let index = history.firstIndex(where: { $0.id == response.turnId && $0.role.isAssistant }) {
+ history[index].modelName = response.modelName
+ 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])
+ }
+ }
+ }
public func sendAndWait(_ id: String, content: String) async throws -> String {
try await send(id, content: content, skillSet: [], references: [])
@@ -535,6 +615,7 @@ public final class ChatService: ChatServiceType, ObservableObject {
model: model != nil ? model : lastUserRequest.model,
modelProviderName: modelProviderName,
agentMode: lastUserRequest.agentMode,
+ customChatModeId: lastUserRequest.customChatModeId,
userLanguage: lastUserRequest.userLanguage,
turnId: id
)
@@ -652,8 +733,22 @@ public final class ChatService: ChatServiceType, ObservableObject {
private func handleProgressBegin(token: String, progress: ConversationProgressBegin) {
guard let workDoneToken = activeRequestId, workDoneToken == token else { return }
- conversationId = progress.conversationId
+ // Only update conversationId for main turns, not subagent turns
+ // Subagent turns have their own conversation ID which should not replace the parent
+ if progress.parentTurnId == nil {
+ conversationId = progress.conversationId
+ }
+
+ // Track all valid conversation IDs for the current turn (main conversation + its subturns)
+ conversationTurnTracking.validConversationIds.insert(progress.conversationId)
+
let turnId = progress.turnId
+ let parentTurnId = progress.parentTurnId
+
+ // Track parent-subturn relationship
+ if let parentTurnId = parentTurnId {
+ conversationTurnTracking.turnParentMap[turnId] = parentTurnId
+ }
Task {
if var lastUserMessage = await memory.history.last(where: { $0.role == .user }) {
@@ -677,10 +772,17 @@ public final class ChatService: ChatServiceType, ObservableObject {
/// Display an initial assistant message immediately after the user sends a message.
/// This improves perceived responsiveness, especially in Agent Mode where the first
/// ProgressReport may take long time.
- let message = ChatMessage(assistantMessageWithId: turnId, chatTabID: chatTabInfo.id, turnStatus: .inProgress)
+ /// Skip creating a new message for subturns - they will be merged into the parent turn
+ if parentTurnId == nil {
+ let message = ChatMessage(
+ assistantMessageWithId: turnId,
+ chatTabID: chatTabInfo.id,
+ turnStatus: .inProgress
+ )
- // will persist in resetOngoingRequest()
- await memory.appendMessage(message)
+ // will persist in resetOngoingRequest()
+ await memory.appendMessage(message)
+ }
}
}
@@ -688,38 +790,83 @@ public final class ChatService: ChatServiceType, ObservableObject {
guard let workDownToken = activeRequestId, workDownToken == token else {
return
}
-
+
+ if let contextSize = progress.contextSize {
+ self.contextSizeInfo = contextSize
+ }
+
let id = progress.turnId
var content = ""
var references: [ConversationReference] = []
var steps: [ConversationProgressStep] = []
var editAgentRounds: [AgentRound] = []
+ let parentTurnId = progress.parentTurnId
if let reply = progress.reply {
content = reply
}
-
+
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 {
+
+ 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
}
-
- // create immutable copies
+
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(
@@ -729,16 +876,151 @@ public final class ChatService: ChatServiceType, ObservableObject {
references: messageReferences,
steps: messageSteps,
editAgentRounds: messageAgentRounds,
+ thinking: messageThinking,
+ parentTurnId: messageParentTurnId,
turnStatus: .inProgress
)
- // will persist in resetOngoingRequest()
await memory.appendMessage(message)
}
}
+ /// 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[.. ConversationCreateResponse? {
guard !isReceivingMessage else { throw CancellationError() }
isReceivingMessage = true
requestType = .conversation
do {
if let conversationId = conversationId {
- try await conversationProvider?
+ return try await conversationProvider?
.createTurn(
with: conversationId,
request: request,
@@ -922,7 +1245,7 @@ public final class ChatService: ChatServiceType, ObservableObject {
requestWithTurns.turns = turns
}
- try await conversationProvider?.createConversation(requestWithTurns, workspaceURL: getWorkspaceURL())
+ return try await conversationProvider?.createConversation(requestWithTurns, workspaceURL: getWorkspaceURL())
}
} catch {
resetOngoingRequest(with: .error)
@@ -946,12 +1269,51 @@ public final class ChatService: ChatServiceType, ObservableObject {
}
}
}
+
+ // MARK: - Certificate Error Detection
+
+ /// Checks if an error is related to SSL certificate issues
+ private func isCertificateError(_ error: Error) -> Bool {
+ let errorDescription = error.localizedDescription.lowercased()
+
+ // Check for certificate error messages
+ if errorDescription.contains("unable to get local issuer certificate") ||
+ errorDescription.contains("self-signed certificate in certificate chain") ||
+ errorDescription.contains("unable_to_get_issuer_cert_locally") {
+ return true
+ }
+
+ // Check GitHubCopilotError with ServerError
+ if let serverError = error as? ServerError,
+ case .serverError(_, let message, _) = serverError {
+ let serverMessage = message.lowercased()
+ if serverMessage.contains("unable to get local issuer certificate") ||
+ serverMessage.contains("self-signed certificate in certificate chain") {
+ return true
+ }
+ }
+
+ return false
+ }
+
+ private func showCertificateErrorMessage(turnId: String?) async {
+ let messageId = turnId ?? UUID().uuidString
+ let errorMessage = ChatMessage(
+ errorMessageWithId: messageId,
+ chatTabID: chatTabInfo.id,
+ errorMessages: [
+ SSLCertificateErrorMessage
+ ]
+ )
+ await memory.appendMessage(errorMessage)
+ }
}
public final class SharedChatService {
public var chatTemplates: [ChatTemplate]? = nil
public var chatAgents: [ChatAgent]? = nil
+ public var conversationModes: [ConversationMode]? = nil
private let conversationProvider: ConversationServiceProvider?
public static let shared = SharedChatService.service()
@@ -980,6 +1342,19 @@ public final class SharedChatService {
return nil
}
+ public func loadConversationModes() async -> [ConversationMode]? {
+ do {
+ if let modes = (try await conversationProvider?.modes()) {
+ self.conversationModes = modes
+ return modes
+ }
+ } catch {
+ // handle error if desired
+ }
+
+ return nil
+ }
+
public func copilotModels() async -> [CopilotModel] {
guard let models = try? await conversationProvider?.models() else { return [] }
return models
@@ -1122,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/Extensions/ChatService+FileEdit.swift b/Core/Sources/ChatService/Extensions/ChatService+FileEdit.swift
index df1509ff..c901a341 100644
--- a/Core/Sources/ChatService/Extensions/ChatService+FileEdit.swift
+++ b/Core/Sources/ChatService/Extensions/ChatService+FileEdit.swift
@@ -25,7 +25,7 @@ extension ChatService {
switch fileEdit.toolName {
case .insertEditIntoFile:
- InsertEditIntoFileTool.applyEdit(for: fileURL, content: fileEdit.originalContent, contextProvider: self)
+ InsertEditIntoFileTool.applyEdit(for: fileURL, content: fileEdit.originalContent)
case .createFile:
try CreateFileTool.undo(for: fileURL)
default:
diff --git a/Core/Sources/ChatService/ToolCalls/AutoApproval/AutoApprovalScope.swift b/Core/Sources/ChatService/ToolCalls/AutoApproval/AutoApprovalScope.swift
new file mode 100644
index 00000000..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 29ee7bef..2eb6b160 100644
--- a/Core/Sources/ChatService/ToolCalls/InsertEditIntoFileTool.swift
+++ b/Core/Sources/ChatService/ToolCalls/InsertEditIntoFileTool.swift
@@ -7,6 +7,34 @@ import JSONRPC
import Logger
import XcodeInspector
import ChatAPIService
+import SystemUtils
+import Workspace
+
+public enum InsertEditError: LocalizedError {
+ case missingEditorElement(file: URL)
+ case openingApplicationUnavailable
+ case fileNotOpenedInXcode
+ case fileURLMismatch(expected: URL, actual: URL?)
+ case fileNotAccessible(URL)
+ case fileHasUnsavedChanges(URL)
+
+ public var errorDescription: String? {
+ switch self {
+ case .missingEditorElement(let file):
+ return "Could not find source editor element for file \(file.lastPathComponent)."
+ case .openingApplicationUnavailable:
+ return "Failed to get the application that opened the file."
+ case .fileNotOpenedInXcode:
+ return "The file is not currently opened in Xcode."
+ case .fileURLMismatch(let expected, let actual):
+ return "The currently focused file URL \(actual?.lastPathComponent ?? "unknown") does not match the expected file URL \(expected.lastPathComponent)."
+ case .fileNotAccessible(let fileURL):
+ return "The file \(fileURL.lastPathComponent) is not accessible."
+ case .fileHasUnsavedChanges(let fileURL):
+ return "The file \(fileURL.lastPathComponent) seems to have unsaved changes in Xcode. Please save the file and try again."
+ }
+ }
+}
public class InsertEditIntoFileTool: ICopilotTool {
public static let name = ToolName.insertEditIntoFile
@@ -30,7 +58,7 @@ public class InsertEditIntoFileTool: ICopilotTool {
let fileURL = URL(fileURLWithPath: filePath)
let originalContent = try String(contentsOf: fileURL, encoding: .utf8)
- InsertEditIntoFileTool.applyEdit(for: fileURL, content: code, contextProvider: contextProvider) { newContent, error in
+ InsertEditIntoFileTool.applyEdit(for: fileURL, content: code) { newContent, error in
if let error = error {
self.completeResponse(
request,
@@ -86,18 +114,11 @@ public class InsertEditIntoFileTool: ICopilotTool {
public static func applyEdit(
for fileURL: URL,
content: String,
- contextProvider: any ToolContextProvider,
xcodeInstance: AppInstanceInspector
) throws -> String {
- // Get the focused element directly from the app (like XcodeInspector does)
- guard let focusedElement: AXUIElement = try? xcodeInstance.appElement.copyValue(key: kAXFocusedUIElementAttribute)
+ guard let editorElement = Self.getEditorElement(by: xcodeInstance, for: fileURL)
else {
- throw NSError(domain: "Failed to access xcode element", code: 0)
- }
-
- // Find the source editor element using XcodeInspector's logic
- guard let editorElement = focusedElement.findSourceEditorElement() else {
- throw NSError(domain: "Could not find source editor element", code: 0)
+ throw InsertEditError.missingEditorElement(file: fileURL)
}
// Check if element supports kAXValueAttribute before reading
@@ -113,10 +134,9 @@ public class InsertEditIntoFileTool: ICopilotTool {
let lines = value.components(separatedBy: .newlines)
- var isInjectedSuccess = false
- var injectionError: Error?
-
do {
+ try Self.checkOpenedFileURL(for: fileURL, xcodeInstance: xcodeInstance)
+
try AXHelper().injectUpdatedCodeWithAccessibilityAPI(
.init(
content: content,
@@ -128,48 +148,132 @@ public class InsertEditIntoFileTool: ICopilotTool {
.inserted(0, [content])
]
),
- focusElement: editorElement,
- onSuccess: {
- Logger.client.info("Content injection succeeded")
- isInjectedSuccess = true
- },
- onError: {
- Logger.client.error("Content injection failed in onError callback")
- }
+ focusElement: editorElement
)
} catch {
- Logger.client.error("Content injection threw error: \(error)")
- if let axError = error as? AXError {
- Logger.client.error("AX Error code during injection: \(axError.rawValue)")
+ Logger.client.error("Failed to inject code for insert edit into file: \(error.localizedDescription)")
+ throw error
+ }
+
+ // Verify the content was applied by reading it back
+ return try Self.getCurrentEditorContent(for: fileURL, by: xcodeInstance)
+ }
+
+ public static func applyEdit(
+ for fileURL: URL,
+ content: String,
+ completion: ((String?, Error?) -> Void)? = nil
+ ) {
+ if SystemUtils.isDeveloperMode || SystemUtils.isPrereleaseBuild {
+ /// Experimental solution: Use file system write for better reliability. Only enable in dev mode or prerelease builds.
+ Self.applyEditWithFileSystem(
+ for: fileURL,
+ content: content,
+ completion: completion
+ )
+ } else {
+ Self.applyEditWithAccessibilityAPI(
+ for: fileURL,
+ content: content,
+ completion: completion
+ )
+ }
+ }
+
+ /// Get the source editor element with retries for specific file URL
+ private static func getEditorElement(
+ by xcodeInstance: AppInstanceInspector,
+ for fileURL: URL,
+ retryTimes: Int = 6,
+ delay: TimeInterval = 0.5
+ ) -> AXUIElement? {
+ var remainingAttempts = max(1, retryTimes)
+
+ while remainingAttempts > 0 {
+ guard let realtimeURL = xcodeInstance.appElement.realtimeDocumentURL,
+ realtimeURL == fileURL,
+ let focusedElement = xcodeInstance.appElement.focusedElement,
+ let editorElement = focusedElement.findSourceEditorElement()
+ else {
+ if remainingAttempts > 1 {
+ Thread.sleep(forTimeInterval: delay)
+ }
+
+ remainingAttempts -= 1
+ continue
}
- injectionError = error
+
+ return editorElement
}
- if !isInjectedSuccess {
- let errorMessage = injectionError?.localizedDescription ?? "Failed to apply edit"
- Logger.client.error("Edit application failed: \(errorMessage)")
- throw NSError(domain: "Failed to apply edit: \(errorMessage)", code: 0)
+ Logger.client.error("Editor element not found for \(fileURL.lastPathComponent) after \(retryTimes) attempts.")
+ return nil
+ }
+
+ // Check if current opened file is the target URL
+ private static func checkOpenedFileURL(
+ for fileURL: URL,
+ xcodeInstance: AppInstanceInspector
+ ) throws {
+ let realtimeDocumentURL = xcodeInstance.realtimeDocumentURL
+
+ if realtimeDocumentURL != fileURL {
+ throw InsertEditError.fileURLMismatch(expected: fileURL, actual: realtimeDocumentURL)
+ }
+ }
+
+ private static func getCurrentEditorContent(for fileURL: URL, by xcodeInstance: AppInstanceInspector) throws -> String {
+ guard let editorElement = getEditorElement(by: xcodeInstance, for: fileURL, retryTimes: 1)
+ else {
+ throw InsertEditError.missingEditorElement(file: fileURL)
}
- // Verify the content was applied by reading it back
+ return try editorElement.copyValue(key: kAXValueAttribute)
+ }
+}
+
+private extension AppInstanceInspector {
+ var realtimeDocumentURL: URL? {
+ appElement.realtimeDocumentURL
+ }
+}
+
+extension InsertEditIntoFileTool {
+ static func applyEditWithFileSystem(
+ for fileURL: URL,
+ content: String,
+ completion: ((String?, Error?) -> Void)? = nil
+ ) {
do {
- let newContent: String = try editorElement.copyValue(key: kAXValueAttribute)
- Logger.client.info("Successfully read back new content, length: \(newContent.count)")
- return newContent
- } catch {
- Logger.client.error("Failed to read back new content: \(error)")
- if let axError = error as? AXError {
- Logger.client.error("AX Error code when reading back: \(axError.rawValue)")
+ guard let diskFileContent = try? String(contentsOf: fileURL) else {
+ throw InsertEditError.fileNotAccessible(fileURL)
}
- throw error
+
+ if let focusedElement = XcodeInspector.shared.focusedElement,
+ focusedElement.isNonNavigatorSourceEditor,
+ focusedElement.realtimeDocumentURL == fileURL,
+ focusedElement.value != diskFileContent
+ {
+ throw InsertEditError.fileHasUnsavedChanges(fileURL)
+ }
+
+ // write content to disk
+ try content.write(to: fileURL, atomically: true, encoding: .utf8)
+
+ Task { @WorkspaceActor in
+ await WorkspaceInvocationCoordinator().invokeFilespaceUpdate(fileURL: fileURL, content: content)
+ if let completion = completion { completion(content, nil) }
+ }
+ } catch {
+ if let completion = completion { completion(nil, error) }
+ Logger.client.info("Failed to apply edit for file at \(fileURL), \(error)")
}
}
- public static func applyEdit(
+ static func applyEditWithAccessibilityAPI(
for fileURL: URL,
content: String,
- contextProvider: any ToolContextProvider,
- completion: ((String?, Error?) -> Void)? = nil
+ completion: ((String?, Error?) -> Void)? = nil,
) {
NSWorkspace.openFileInXcode(fileURL: fileURL) { app, error in
do {
@@ -177,25 +281,23 @@ public class InsertEditIntoFileTool: ICopilotTool {
guard let app = app
else {
- throw NSError(domain: "Failed to get the app that opens file.", code: 0)
+ throw InsertEditError.openingApplicationUnavailable
}
let appInstanceInspector = AppInstanceInspector(runningApplication: app)
guard appInstanceInspector.isXcode
else {
- throw NSError(domain: "The file is not opened in Xcode.", code: 0)
+ throw InsertEditError.fileNotOpenedInXcode
}
let newContent = try applyEdit(
for: fileURL,
content: content,
- contextProvider: contextProvider,
xcodeInstance: appInstanceInspector
)
Task {
- // Force to notify the CLS about the new change within the document before edit_file completion.
- try? await contextProvider.notifyChangeTextDocument(fileURL: fileURL, content: newContent, version: 0)
+ await WorkspaceInvocationCoordinator().invokeFilespaceUpdate(fileURL: fileURL, content: newContent)
if let completion = completion { completion(newContent, nil) }
}
} catch {
diff --git a/Core/Sources/ChatService/ToolCalls/ToolCallStatusUpdater.swift b/Core/Sources/ChatService/ToolCalls/ToolCallStatusUpdater.swift
new file mode 100644
index 00000000..b8058ccb
--- /dev/null
+++ b/Core/Sources/ChatService/ToolCalls/ToolCallStatusUpdater.swift
@@ -0,0 +1,104 @@
+import ChatAPIService
+import ConversationServiceProvider
+import Foundation
+
+/// Helper methods for updating tool call status in chat history
+/// Handles both main turn tool calls and subagent tool calls
+struct ToolCallStatusUpdater {
+ /// Finds the message containing the tool call, handling both main turns and subturns
+ static func findMessageContainingToolCall(
+ _ toolCallRequest: ToolCallRequest?,
+ conversationTurnTracking: ConversationTurnTrackingState,
+ history: [ChatMessage]
+ ) async -> ChatMessage? {
+ guard let request = toolCallRequest else { return nil }
+
+ // If this is a subturn, find the parent turn; otherwise use the request's turnId
+ let turnIdToFind = conversationTurnTracking.turnParentMap[request.turnId] ?? request.turnId
+
+ return history.first(where: { $0.id == turnIdToFind && $0.role == .assistant })
+ }
+
+ /// Searches for a tool call in agent rounds (including nested subagent rounds) and creates an update
+ ///
+ /// Note: Parent turns can have multiple sequential subturns, but they don't appear simultaneously.
+ /// Subturns are merged into the parent's last round's subAgentRounds array by ChatMemory.
+ static func findAndUpdateToolCall(
+ toolCallId: String,
+ newStatus: AgentToolCall.ToolCallStatus,
+ in agentRounds: [AgentRound]
+ ) -> AgentRound? {
+ // First, search in main rounds (for regular tool calls)
+ for round in agentRounds {
+ if let toolCalls = round.toolCalls {
+ for toolCall in toolCalls where toolCall.id == toolCallId {
+ return AgentRound(
+ roundId: round.roundId,
+ reply: "",
+ toolCalls: [
+ AgentToolCall(
+ id: toolCallId,
+ name: toolCall.name,
+ toolType: toolCall.toolType,
+ status: newStatus
+ ),
+ ]
+ )
+ }
+ }
+ }
+
+ // If not found in main rounds, search in subagent rounds (for subturn tool calls)
+ // Subturns are nested under the parent round's subAgentRounds
+ for round in agentRounds {
+ guard let subAgentRounds = round.subAgentRounds else { continue }
+
+ for subRound in subAgentRounds {
+ guard let toolCalls = subRound.toolCalls else { continue }
+
+ for toolCall in toolCalls where toolCall.id == toolCallId {
+ // Create an update that will be merged into the parent's subAgentRounds
+ // ChatMemory.appendMessage will handle the merging logic
+ let subagentRound = AgentRound(
+ roundId: subRound.roundId,
+ reply: "",
+ toolCalls: [
+ AgentToolCall(
+ id: toolCallId,
+ name: toolCall.name,
+ toolType: toolCall.toolType,
+ status: newStatus
+ ),
+ ]
+ )
+ return AgentRound(
+ roundId: round.roundId,
+ reply: "",
+ toolCalls: [],
+ subAgentRounds: [subagentRound]
+ )
+ }
+ }
+ }
+
+ return nil
+ }
+
+ /// Creates a message update with the new tool call status
+ static func createMessageUpdate(
+ targetMessage: ChatMessage,
+ updatedRound: AgentRound
+ ) -> ChatMessage {
+ return ChatMessage(
+ id: targetMessage.id,
+ chatTabID: targetMessage.chatTabID,
+ clsTurnID: targetMessage.clsTurnID,
+ role: .assistant,
+ content: "",
+ references: [],
+ steps: [],
+ editAgentRounds: [updatedRound],
+ turnStatus: .inProgress
+ )
+ }
+}
diff --git a/Core/Sources/ChatService/WorkspaceInvocationCoordinator.swift b/Core/Sources/ChatService/WorkspaceInvocationCoordinator.swift
new file mode 100644
index 00000000..c7b28d16
--- /dev/null
+++ b/Core/Sources/ChatService/WorkspaceInvocationCoordinator.swift
@@ -0,0 +1,11 @@
+import Foundation
+import Workspace
+import Dependencies
+
+struct WorkspaceInvocationCoordinator {
+ @Dependency(\.workspaceInvoker) private var workspaceInvoker
+
+ func invokeFilespaceUpdate(fileURL: URL, content: String) async {
+ await workspaceInvoker.invokeFilespaceUpdate(fileURL, content)
+ }
+}
diff --git a/Core/Sources/ConversationTab/Chat.swift b/Core/Sources/ConversationTab/Chat.swift
index 19e195be..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,12 +30,18 @@ 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] = []
public var codeReviewRound: CodeReviewRound? = nil
public var fileEdits: [FileEdit] = []
public var turnStatus: ChatMessage.TurnStatus? = nil
public let requestType: RequestType
+ public var modelName: String? = nil
+ public var modelProviderName: String? = nil
+ public var billingMultiplier: Float? = nil
+ public var reasoningEffort: String? = nil
public init(
id: String,
@@ -46,12 +53,18 @@ public struct DisplayedChatMessage: Equatable {
suggestedTitle: String? = nil,
errorMessages: [String] = [],
steps: [ConversationProgressStep] = [],
+ thinking: [MessageThinking] = [],
editAgentRounds: [AgentRound] = [],
+ parentTurnId: String? = nil,
panelMessages: [CopilotShowMessageParams] = [],
codeReviewRound: CodeReviewRound? = nil,
fileEdits: [FileEdit] = [],
turnStatus: ChatMessage.TurnStatus? = nil,
- requestType: RequestType
+ requestType: RequestType,
+ modelName: String? = nil,
+ modelProviderName: String? = nil,
+ billingMultiplier: Float? = nil,
+ reasoningEffort: String? = nil
) {
self.id = id
self.role = role
@@ -62,12 +75,18 @@ 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
self.codeReviewRound = codeReviewRound
self.fileEdits = fileEdits
self.turnStatus = turnStatus
self.requestType = requestType
+ self.modelName = modelName
+ self.modelProviderName = modelProviderName
+ self.billingMultiplier = billingMultiplier
+ self.reasoningEffort = reasoningEffort
}
}
@@ -195,19 +214,22 @@ struct Chat {
var contextProvider: ChatContextProvider
var focusedField: Field?
var currentEditor: ConversationFileReference?
+ var handOffClicked: Bool = false
init(
mode: EditorMode = .input,
contexts: [EditorMode: ChatContext] = [.input: .empty()],
contextProvider: ChatContextProvider = .init(),
focusedField: Field? = nil,
- currentEditor: ConversationFileReference? = nil
+ currentEditor: ConversationFileReference? = nil,
+ handOffClicked: Bool = false
) {
self.mode = mode
self.contexts = contexts
self.contextProvider = contextProvider
self.focusedField = focusedField
self.currentEditor = currentEditor
+ self.handOffClicked = handOffClicked
}
func context(for mode: EditorMode) -> ChatContext {
@@ -282,16 +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] {
@@ -329,13 +357,16 @@ struct Chat {
struct EnvironmentState: Equatable {
var isAgentMode: Bool
var workspaceURL: URL?
+ var selectedAgent: ConversationMode
init(
isAgentMode: Bool = AppState.shared.isAgentModeEnabled(),
- workspaceURL: URL? = nil
+ workspaceURL: URL? = nil,
+ selectedAgent: ConversationMode = .defaultAgent
) {
self.isAgentMode = isAgentMode
self.workspaceURL = workspaceURL
+ self.selectedAgent = selectedAgent
}
}
@@ -383,6 +414,7 @@ struct Chat {
diffViewerController: DiffViewWindowController? = nil,
isAgentMode: Bool = AppState.shared.isAgentModeEnabled(),
workspaceURL: URL? = nil,
+ selectedAgent: ConversationMode = .defaultAgent,
chatMenu: ChatMenu.State = .init(),
codeReviewState: ConversationCodeReviewFeature.State = .init()
) {
@@ -404,7 +436,8 @@ struct Chat {
),
environment: EnvironmentState(
isAgentMode: isAgentMode,
- workspaceURL: workspaceURL
+ workspaceURL: workspaceURL,
+ selectedAgent: selectedAgent
),
chatMenu: chatMenu,
codeReviewState: codeReviewState
@@ -436,11 +469,26 @@ struct Chat {
set { conversation.isReceivingMessage = newValue }
}
+ var isSummarizingConversation: Bool {
+ get { conversation.isSummarizingConversation }
+ set { conversation.isSummarizingConversation = newValue }
+ }
+
var requestType: RequestType? {
get { conversation.requestType }
set { conversation.requestType = newValue }
}
+ var contextSizeInfo: ContextSizeInfo? {
+ get { conversation.contextSizeInfo }
+ set { conversation.contextSizeInfo = newValue }
+ }
+
+ var handOffClicked: Bool {
+ get { editor.handOffClicked }
+ set { editor.handOffClicked = newValue }
+ }
+
var focusedField: Field? {
get { editor.focusedField }
set { editor.focusedField = newValue }
@@ -487,6 +535,11 @@ struct Chat {
set { environment.workspaceURL = newValue }
}
+ var selectedAgent: ConversationMode {
+ get { environment.selectedAgent }
+ set { environment.selectedAgent = newValue }
+ }
+
/// Not including the one being edited
var editUserMessageEffectedMessages: [DisplayedChatMessage] {
conversation.editUserMessageEffectedMessages(for: editor.mode)
@@ -554,6 +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)
@@ -561,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)
@@ -579,6 +635,7 @@ struct Chat {
case removeSelectedImage(ImageReference)
case followUpButtonClicked(String, String)
+ case handOffButtonClicked(HandOff)
// Agent File Edit
case undoEdits(fileURLs: [URL])
@@ -589,6 +646,7 @@ struct Chat {
case setDiffViewerController(chat: StoreOf)
case agentModeChanged(Bool)
+ case selectedAgentChanged(ConversationMode)
// Code Review
case codeReview(ConversationCodeReviewFeature.Action)
@@ -608,6 +666,8 @@ struct Chat {
case undoCheckPoint // Revert the restore
case discardCheckPoint
case reloadWorkingset(DisplayedChatMessage)
+
+ case openAutoApproveSettings
}
let service: ChatService
@@ -618,6 +678,7 @@ struct Chat {
case observeIsReceivingMessageChange(UUID)
case sendMessage(UUID)
case observeFileEditChange(UUID)
+ case observeContextSizeInfoChange(UUID)
case observeFixErrorNotification(UUID)
}
@@ -647,11 +708,23 @@ struct Chat {
await send(.focusOnTextField)
await send(.refresh)
await send(.observeFixErrorNotification)
-
+
+ let selectedAgentSubModeId = AppState.shared.getSelectedAgentSubMode()
+ if let modes = await SharedChatService.shared.loadConversationModes(),
+ let currentMode = modes.first(where: { $0.id == selectedAgentSubModeId }) {
+ await send(.selectedAgentChanged(currentMode))
+ }
+
let publisher = NotificationCenter.default.publisher(for: .gitHubCopilotChatModeDidChange)
for await _ in publisher.values {
let isAgentMode = AppState.shared.isAgentModeEnabled()
await send(.agentModeChanged(isAgentMode))
+
+ let selectedAgentSubModeId = AppState.shared.getSelectedAgentSubMode()
+ if let modes = await SharedChatService.shared.loadConversationModes(),
+ let currentMode = modes.first(where: { $0.id == selectedAgentSubModeId }) {
+ await send(.selectedAgentChanged(currentMode))
+ }
}
}
@@ -672,7 +745,9 @@ 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(
scope: AppState.shared.modelScope()
)?.supportVision ?? false
@@ -707,7 +782,9 @@ struct Chat {
references: references,
model: selectedModelFamily,
modelProviderName: selectedModel?.providerName,
+ reasoningEffort: reasoningEffort,
agentMode: agentMode,
+ customChatModeId: selectedAgentSubMode,
userLanguage: chatResponseLocale
)
}.cancellable(id: CancelID.sendMessage(self.id))
@@ -717,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
@@ -740,6 +828,7 @@ struct Chat {
)?.modelFamily
let references = state.attachedReferences
let agentMode = AppState.shared.isAgentModeEnabled()
+ let selectedAgentSubMode = AppState.shared.getSelectedAgentSubMode()
return .run { send in
await send(.resetContextProvider)
@@ -753,10 +842,36 @@ struct Chat {
references: references,
model: selectedModelFamily,
modelProviderName: selectedModel?.providerName,
+ reasoningEffort: selectedModel.flatMap { AppState.shared.effectiveReasoningEffort(for: $0) },
agentMode: agentMode,
+ customChatModeId: selectedAgentSubMode,
userLanguage: chatResponseLocale
)
}.cancellable(id: CancelID.sendMessage(self.id))
+
+ case let .handOffButtonClicked(handOff):
+ state.handOffClicked = true
+ let agent = handOff.agent
+ let prompt = handOff.prompt
+ let shouldSend = handOff.send ?? false
+
+ return .run { send in
+ // Find and switch to the target agent
+ let modes = await SharedChatService.shared.loadConversationModes() ?? []
+ if let targetAgent = modes.first(where: { $0.name.lowercased() == agent.lowercased() }) {
+ await send(.selectedAgentChanged(targetAgent))
+ }
+
+ // If send is true, send the prompt message
+ if shouldSend && !prompt.isEmpty {
+ await send(.updateTypedMessage(prompt))
+ let id = UUID().uuidString
+ await send(.sendButtonTapped(id))
+ } else if !prompt.isEmpty {
+ // Just populate the message field
+ await send(.updateTypedMessage(prompt))
+ }
+ }
case .returnButtonTapped:
state.typedMessage += "\n"
@@ -858,6 +973,7 @@ struct Chat {
await send(.observeHistoryChange)
await send(.observeIsReceivingMessageChange)
await send(.observeFileEditChange)
+ await send(.observeContextSizeInfoChange)
}
case .observeHistoryChange:
@@ -883,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()
}
@@ -917,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]()
@@ -943,12 +1079,18 @@ struct Chat {
suggestedTitle: message.suggestedTitle,
errorMessages: message.errorMessages,
steps: message.steps,
+ thinking: message.thinking,
editAgentRounds: message.editAgentRounds,
+ parentTurnId: message.parentTurnId,
panelMessages: message.panelMessages,
codeReviewRound: message.codeReviewRound,
fileEdits: message.fileEdits,
turnStatus: message.turnStatus,
- requestType: message.requestType
+ requestType: message.requestType,
+ modelName: message.modelName,
+ modelProviderName: message.modelProviderName,
+ billingMultiplier: message.billingMultiplier,
+ reasoningEffort: message.reasoningEffort
))
return all
@@ -958,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
@@ -1091,9 +1238,14 @@ struct Chat {
case let .agentModeChanged(isAgentMode):
state.isAgentMode = isAgentMode
return .none
-
+
+ case let .selectedAgentChanged(mode):
+ state.selectedAgent = mode
+ state.handOffClicked = false
+ return .none
+
// MARK: - Code Review
- case let .codeReview(.request(group)):
+ case .codeReview(.request(_)):
return .run { send in
await send(.discardCheckPoint)
}
@@ -1184,8 +1336,10 @@ struct Chat {
scope: AppState.shared.modelScope()
)?.modelFamily
let agentMode = AppState.shared.isAgentModeEnabled()
+ // TODO: if we need to switch to agent mode or keep the current mode
+ let selectedAgentSubMode = AppState.shared.getSelectedAgentSubMode()
- return .run { _ in
+ return .run { _ in
try await service.send(
UUID().uuidString,
content: message,
@@ -1193,7 +1347,9 @@ struct Chat {
references: references,
model: selectedModelFamily,
modelProviderName: selectedModel?.providerName,
+ reasoningEffort: selectedModel.flatMap { AppState.shared.effectiveReasoningEffort(for: $0) },
agentMode: agentMode,
+ customChatModeId: selectedAgentSubMode,
userLanguage: chatResponseLocale
)
}.cancellable(id: CancelID.sendMessage(self.id))
@@ -1335,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 0cc83479..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 {
@@ -39,8 +40,15 @@ public struct ChatPanel: View {
ChatPanelMessages(chat: chat)
.accessibilityElement(children: .combine)
.accessibilityLabel("Chat Messages Group")
-
- if let _ = chat.history.last?.followUp {
+
+ if chat.isAgentMode, let handOffs = chat.selectedAgent.handOffs, !handOffs.isEmpty,
+ chat.history.contains(where: { $0.role == .assistant && $0.turnStatus != .inProgress }),
+ !chat.handOffClicked {
+ ChatHandOffs(chat: chat)
+ .scaledPadding(.vertical, 8)
+ .scaledPadding(.horizontal, 16)
+ .dimWithExitEditMode(chat)
+ } else if let _ = chat.history.last?.followUp {
ChatFollowUp(chat: chat)
.scaledPadding(.vertical, 8)
.scaledPadding(.horizontal, 16)
@@ -48,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)
@@ -128,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()
@@ -147,16 +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 {
@@ -174,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(
@@ -302,11 +361,13 @@ struct ChatPanelMessages: View {
struct ExtraSpacingInResponding: View {
let chat: StoreOf
+
+ @AppStorage(\.fontScale) private var fontScale: Double
var body: some View {
WithPerceptionTracking {
if chat.isReceivingMessage {
- Spacer(minLength: 12)
+ Spacer(minLength: 12 * fontScale)
}
}
}
@@ -404,11 +465,16 @@ struct ChatHistory: View {
}
if message.role != .ignored && index < currentFilteredHistory.count - 1 {
- if message.role == .assistant {
- // check point
- CheckPoint(chat: chat, messageId: message.id)
- .padding(.vertical, 8)
- .padding(.trailing, 8)
+ if message.role == .assistant && message.parentTurnId == nil {
+ let nextMessage = currentFilteredHistory[index + 1]
+ let hasContent = !message.text.isEmpty || !message.editAgentRounds.isEmpty
+ let nextIsNotSubturn = nextMessage.parentTurnId != message.id
+
+ if hasContent && nextIsNotSubturn {
+ CheckPoint(chat: chat, messageId: message.id)
+ .padding(.vertical, 8)
+ .padding(.trailing, 8)
+ }
}
}
@@ -486,12 +552,55 @@ struct ChatFollowUp: View {
}
.buttonStyle(.plain)
.onHover { isHovered in
- if isHovered {
- NSCursor.pointingHand.push()
- } else {
- NSCursor.pop()
+ DispatchQueue.main.async {
+ if isHovered {
+ NSCursor.pointingHand.push()
+ } else {
+ NSCursor.pop()
+ }
}
}
+ .onDisappear {
+ NSCursor.pop()
+ }
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ }
+}
+
+struct ChatHandOffs: View {
+ let chat: StoreOf
+ @AppStorage(\.chatFontSize) var chatFontSize
+
+ var body: some View {
+ WithPerceptionTracking {
+ VStack(alignment: .leading) {
+ Text("PROCEED FROM \(chat.selectedAgent.name.uppercased())")
+ .foregroundStyle(.secondary)
+ .scaledPadding(.horizontal, 4)
+ .scaledPadding(.bottom, -4)
+
+ FlowLayout(mode: .vstack, items: chat.selectedAgent.handOffs ?? [], itemSpacing: 4) { item in
+ Button(action: {
+ chat.send(.handOffButtonClicked(item))
+ }) {
+ Text(item.label)
+ }
+ .buttonStyle(.bordered)
+ .onHover { isHovered in
+ DispatchQueue.main.async {
+ if isHovered {
+ NSCursor.pointingHand.push()
+ } else {
+ NSCursor.pop()
+ }
+ }
+ }
+ .onDisappear {
+ NSCursor.pop()
+ }
}
}
.frame(maxWidth: .infinity, alignment: .leading)
diff --git a/Core/Sources/ConversationTab/CodeBlockHighlighter.swift b/Core/Sources/ConversationTab/CodeBlockHighlighter.swift
index 553f5976..3cecf903 100644
--- a/Core/Sources/ConversationTab/CodeBlockHighlighter.swift
+++ b/Core/Sources/ConversationTab/CodeBlockHighlighter.swift
@@ -86,13 +86,13 @@ struct AsyncCodeBlockView: View {
Group {
if let highlighted = storage.highlighted {
Text(highlighted)
- .frame(maxWidth: .infinity, alignment: .leading)
} else {
Text(content).font(.init(font))
- .frame(maxWidth: .infinity, alignment: .leading)
}
}
- .frame(maxWidth: .infinity)
+ .lineLimit(nil)
+ .fixedSize(horizontal: false, vertical: true)
+ .frame(maxWidth: .infinity, alignment: .leading)
.onAppear {
storage.highlight(debounce: false, for: self)
}
diff --git a/Core/Sources/ConversationTab/FilePicker.swift b/Core/Sources/ConversationTab/FilePicker.swift
index ca2e3494..284c52ec 100644
--- a/Core/Sources/ConversationTab/FilePicker.swift
+++ b/Core/Sources/ConversationTab/FilePicker.swift
@@ -203,9 +203,8 @@ struct FileRowView: View {
var body: some View {
WithPerceptionTracking {
- HStack {
+ HStack(alignment: .center) {
drawFileIcon(ref.url, isDirectory: ref.isDirectory)
- .resizable()
.scaledToFit()
.scaledFrame(width: 16, height: 16)
.hoverSecondaryForeground(isHovered: selectedId == id)
diff --git a/Core/Sources/ConversationTab/ModelPicker/ModelPicker.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModeAndModelPickerPicker.swift
similarity index 58%
rename from Core/Sources/ConversationTab/ModelPicker/ModelPicker.swift
rename to Core/Sources/ConversationTab/ModeAndModelPicker/ModeAndModelPickerPicker.swift
index 937bc5bd..e8302f4e 100644
--- a/Core/Sources/ConversationTab/ModelPicker/ModelPicker.swift
+++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModeAndModelPickerPicker.swift
@@ -8,15 +8,15 @@ import HostAppActivator
import SharedUIComponents
import ConversationServiceProvider
-struct ModelPicker: View {
+struct ModeAndModelPicker: View {
+ let projectRootURL: URL?
+ @Binding var selectedAgent: ConversationMode
+
@State private var selectedModel: LLMModel?
- @State private var isHovered = false
- @State private var isPressed = false
@ObservedObject private var modelManager = CopilotModelManagerObservable.shared
static var lastRefreshModelsTime: Date = .init(timeIntervalSince1970: 0)
@State private var chatMode = "Ask"
- @State private var isAgentPickerHovered = false
// Separate caches for both scopes
@State private var askScopeCache: ScopeCache = ScopeCache()
@@ -25,25 +25,12 @@ struct ModelPicker: View {
@State var isMCPFFEnabled: Bool
@State var isBYOKFFEnabled: Bool
@State private var cancellables = Set()
-
- @StateObject private var fontScaleManager = FontScaleManager.shared
-
- var fontScale: Double {
- fontScaleManager.currentScale
- }
-
- let minimumPadding: Int = 48
- let attributes: [NSAttributedString.Key: NSFont] = [.font: NSFont.systemFont(ofSize: NSFont.systemFontSize)]
-
- var spaceWidth: CGFloat {
- "\u{200A}".size(withAttributes: attributes).width
- }
- var minimumPaddingWidth: CGFloat {
- spaceWidth * CGFloat(minimumPadding)
- }
+ let attributes: [NSAttributedString.Key: NSFont] = ModelMenuItemFormatter.attributes
- init() {
+ init(projectRootURL: URL?, selectedAgent: Binding) {
+ self.projectRootURL = projectRootURL
+ self._selectedAgent = selectedAgent
let initialModel = AppState.shared.getSelectedModel() ??
CopilotModelManager.getDefaultChatModel()
self._selectedModel = State(initialValue: initialModel)
@@ -79,26 +66,11 @@ struct ModelPicker: View {
AppState.shared.isAgentModeEnabled() ? agentScopeCache : askScopeCache
}
- // Helper method to format multiplier text
- func formatMultiplierText(for billing: CopilotModelBilling?) -> String {
- guard let billingInfo = billing else { return "" }
-
- let multiplier = billingInfo.multiplier
- if multiplier == 0 {
- return "Included"
- } else {
- let numberPart = multiplier.truncatingRemainder(dividingBy: 1) == 0
- ? String(format: "%.0f", multiplier)
- : String(format: "%.2f", multiplier)
- return "\(numberPart)x"
- }
- }
-
// Update cache for specific scope only if models changed
func updateModelCacheIfNeeded(for scope: PromptTemplateScope) {
- let currentModels = scope == .agentPanel ?
- modelManager.availableAgentModels + modelManager.availableAgentBYOKModels :
- modelManager.availableChatModels + modelManager.availableChatBYOKModels
+ let clsModels = scope == .agentPanel ? modelManager.availableAgentModels : modelManager.availableChatModels
+ let byokModels = isBYOKFFEnabled ? (scope == .agentPanel ? modelManager.availableAgentBYOKModels : modelManager.availableChatBYOKModels) : []
+ let currentModels = clsModels + byokModels
let modelsHash = currentModels.hashValue
if scope == .agentPanel {
@@ -116,19 +88,13 @@ struct ModelPicker: View {
var maxWidth: CGFloat = 0
for model in models {
- var multiplierText = ""
- if model.billing != nil {
- multiplierText = formatMultiplierText(for: model.billing)
- } else if let providerName = model.providerName, !providerName.isEmpty {
- // For BYOK models, show the provider name
- multiplierText = providerName
- }
- newCache[model.modelName.appending(model.providerName ?? "")] = multiplierText
-
+ let multiplierText = ModelMenuItemFormatter.getMultiplierText(for: model)
+ newCache[model.id.appending(model.providerName ?? "")] = multiplierText
+
let displayName = "β \(model.displayName ?? model.modelName)"
let displayNameWidth = displayName.size(withAttributes: attributes).width
let multiplierWidth = multiplierText.isEmpty ? 0 : multiplierText.size(withAttributes: attributes).width
- let totalWidth = displayNameWidth + minimumPaddingWidth + multiplierWidth
+ let totalWidth = displayNameWidth + ModelMenuItemFormatter.minimumPaddingWidth + multiplierWidth
maxWidth = max(maxWidth, totalWidth)
}
@@ -150,12 +116,13 @@ struct ModelPicker: View {
allAvailableModels += byokModels
}
- // Check if current model exists in available models for current scope using model comparison
- let modelExists = allAvailableModels.contains { model in
+ // Find the fresh model from available models that matches the persisted selection.
+ // This ensures transient fields like degradationReason stay up to date.
+ let freshModel = allAvailableModels.first { model in
model == currentModel
}
-
- if !modelExists && currentModel != nil {
+
+ if freshModel == nil && currentModel != nil {
// Switch to default model if current model is not available
if let fallbackModel = defaultModel {
AppState.shared.setSelectedModel(fallbackModel)
@@ -168,7 +135,12 @@ struct ModelPicker: 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
}
}
@@ -176,11 +148,23 @@ struct ModelPicker: View {
self.chatMode = AppState.shared.getSelectedChatMode()
}
- func switchModelsForScope(_ scope: PromptTemplateScope) {
+ func switchModelsForScope(_ scope: PromptTemplateScope, model: String?) {
let newModeModels = CopilotModelManager.getAvailableChatLLMs(
scope: scope
) + BYOKModelManager.getAvailableChatLLMs(scope: scope)
+ // If a model string is provided, try to parse and find it
+ if let modelString = model {
+ if let parsedModel = parseModelString(modelString, from: newModeModels) {
+ // Model exists in the scope, set it
+ AppState.shared.setSelectedModel(parsedModel)
+ self.updateCurrentModel()
+ updateModelCacheIfNeeded(for: scope)
+ return
+ }
+ // If model doesn't exist in scope, fall through to default behavior
+ }
+
if let currentModel = AppState.shared.getSelectedModel() {
if !newModeModels.isEmpty && !newModeModels.contains(where: { $0 == currentModel }) {
let defaultModel = CopilotModelManager.getDefaultChatModel(scope: scope)
@@ -196,82 +180,53 @@ struct ModelPicker: View {
updateModelCacheIfNeeded(for: scope)
}
- // Model picker menu component
- private var modelPickerMenu: some View {
- Menu {
- // Group models by premium status
- let premiumModels = copilotModels.filter {
- $0.billing?.isPremium == true
- }
- let standardModels = copilotModels.filter {
- $0.billing?.isPremium == false || $0.billing == nil
- }
+ // Parse model string in format "{Model DisplayName} ({providerName or copilot})"
+ // If no parentheses, defaults to Copilot model
+ private func parseModelString(_ modelString: String, from availableModels: [LLMModel]) -> LLMModel? {
+ var displayName: String
+ var isCopilotModel: Bool
+ var provider: String = ""
+
+ // Extract display name and provider from the format: "DisplayName (provider)"
+ if let openParenIndex = modelString.lastIndex(of: "("),
+ let closeParenIndex = modelString.lastIndex(of: ")"),
+ openParenIndex < closeParenIndex {
- // Display standard models section if available
- modelSection(title: "Standard Models", models: standardModels)
+ let displayNameEndIndex = modelString.index(before: openParenIndex)
+ displayName = String(modelString[.. some View {
- if !models.isEmpty {
- Section(title) {
- ForEach(models, id: \.self) { model in
- modelButton(for: model)
- }
+
+ // Search in available models
+ return availableModels.first { model in
+ let matchesDisplayName = (model.displayName ?? model.modelName) == displayName
+
+ if isCopilotModel {
+ // For Copilot models, providerName should be nil or empty
+ return matchesDisplayName && (model.providerName == nil || model.providerName?.isEmpty == true)
+ } else {
+ // For BYOK models, providerName should match (case-insensitive)
+ guard let modelProvider = model.providerName else { return false }
+ return matchesDisplayName && modelProvider.lowercased() == provider.lowercased()
}
}
}
- // Helper function to create a model selection button
- private func modelButton(for model: LLMModel) -> some View {
- Button {
- AppState.shared.setSelectedModel(model)
- } label: {
- Text(createModelMenuItemAttributedString(
- modelName: model.displayName ?? model.modelName,
- isSelected: selectedModel == model,
- cachedMultiplierText: currentCache.modelMultiplierCache[model.modelName.appending(model.providerName ?? "")] ?? ""
- ))
- }
- }
-
private var mcpButton: some View {
Group {
if isMCPFFEnabled {
Button(action: {
- try? launchHostAppToolsSettings()
+ let currentSubMode = AppState.shared.getSelectedAgentSubMode()
+ try? launchHostAppToolsSettings(currentAgentSubMode: currentSubMode)
}) {
mcpIcon.foregroundColor(.primary.opacity(0.85))
}
@@ -301,7 +256,12 @@ struct ModelPicker: View {
WithPerceptionTracking {
HStack(spacing: 0) {
// Custom segmented control with color change
- ChatModePicker(chatMode: $chatMode, onScopeChange: switchModelsForScope)
+ ChatModePicker(
+ projectRootURL: projectRootURL,
+ chatMode: $chatMode,
+ selectedAgent: $selectedAgent,
+ onScopeChange: switchModelsForScope
+ )
.onAppear {
updateAgentPicker()
}
@@ -317,7 +277,13 @@ struct ModelPicker: View {
// Model Picker
Group {
if !copilotModels.isEmpty && selectedModel != nil {
- modelPickerMenu
+ ChatModelPicker(
+ selectedModel: selectedModel,
+ copilotModels: copilotModels,
+ byokModels: byokModels,
+ isBYOKFFEnabled: isBYOKFFEnabled,
+ currentCache: currentCache
+ )
} else {
EmptyView()
}
@@ -366,15 +332,6 @@ struct ModelPicker: View {
}
}
- func labelWidth() -> CGFloat {
- guard let selectedModel = selectedModel else { return 100 }
- let displayName = selectedModel.displayName ?? selectedModel.modelName
- let width = displayName.size(
- withAttributes: attributes
- ).width
- return CGFloat(width * fontScale + 20)
- }
-
@MainActor
func refreshModels() async {
let now = Date()
@@ -389,42 +346,12 @@ struct ModelPicker: View {
}
}
- private func createModelMenuItemAttributedString(
- modelName: String,
- isSelected: Bool,
- cachedMultiplierText: String
- ) -> AttributedString {
- let displayName = isSelected ? "β \(modelName)" : " \(modelName)"
-
- var fullString = displayName
- var attributedString = AttributedString(fullString)
-
- if !cachedMultiplierText.isEmpty {
- let displayNameWidth = displayName.size(withAttributes: attributes).width
- let multiplierTextWidth = cachedMultiplierText.size(withAttributes: attributes).width
- let neededPaddingWidth = currentCache.cachedMaxWidth - displayNameWidth - multiplierTextWidth
- let finalPaddingWidth = max(neededPaddingWidth, minimumPaddingWidth)
-
- let numberOfSpaces = Int(round(finalPaddingWidth / spaceWidth))
- let padding = String(repeating: "\u{200A}", count: max(minimumPadding, numberOfSpaces))
- fullString = "\(displayName)\(padding)\(cachedMultiplierText)"
-
- attributedString = AttributedString(fullString)
-
- if let range = attributedString.range(
- of: cachedMultiplierText,
- options: .backwards
- ) {
- attributedString[range].foregroundColor = .secondary
- }
- }
-
- return attributedString
- }
}
struct ModelPicker_Previews: PreviewProvider {
+ @State static var agent: ConversationMode = .defaultAgent
+
static var previews: some View {
- ModelPicker()
+ ModeAndModelPicker(projectRootURL: nil, selectedAgent: $agent)
}
}
diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeButton.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeButton.swift
new file mode 100644
index 00000000..683f8091
--- /dev/null
+++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeButton.swift
@@ -0,0 +1,372 @@
+import AppKit
+import ConversationServiceProvider
+import Persist
+import SharedUIComponents
+import SwiftUI
+
+// MARK: - Custom NSButton that accepts clicks anywhere within its bounds
+class ClickThroughButton: NSButton {
+ override func hitTest(_ point: NSPoint) -> NSView? {
+ // If the point is within our bounds, return self (the button)
+ // This ensures clicks on subviews are handled by the button
+ if self.bounds.contains(point) {
+ return self
+ }
+ return super.hitTest(point)
+ }
+}
+
+// MARK: - Agent Mode Button
+
+struct AgentModeButton: NSViewRepresentable {
+ @StateObject private var fontScaleManager = FontScaleManager.shared
+
+ private var fontScale: Double {
+ fontScaleManager.currentScale
+ }
+
+ let title: String
+ let isSelected: Bool
+ let activeBackground: Color
+ let activeTextColor: Color
+ let inactiveTextColor: Color
+ let chatMode: String
+ let builtInAgentModes: [ConversationMode]
+ let customAgents: [ConversationMode]
+ let selectedAgent: ConversationMode
+ let selectedIconName: String?
+ let isCustomAgentEnabled: Bool
+ let onSelectAgent: (ConversationMode) -> Void
+ let onEditAgent: (ConversationMode) -> Void
+ let onDeleteAgent: (ConversationMode) -> Void
+ let onCreateAgent: () -> Void
+
+ func makeNSView(context: Context) -> NSView {
+ let containerView = NSView()
+ containerView.translatesAutoresizingMaskIntoConstraints = false
+
+ let button = ClickThroughButton()
+ button.title = ""
+ button.bezelStyle = .inline
+ button.setButtonType(.momentaryPushIn)
+ button.isBordered = false
+ button.target = context.coordinator
+ button.action = #selector(Coordinator.buttonClicked(_:))
+ button.translatesAutoresizingMaskIntoConstraints = false
+
+ // Create icon for agent mode
+ let iconImageView = NSImageView()
+ iconImageView.translatesAutoresizingMaskIntoConstraints = false
+ iconImageView.imageScaling = .scaleProportionallyDown
+
+ // Create chevron icon
+ let chevronView = NSImageView()
+ let chevronImage = NSImage(systemSymbolName: "chevron.down", accessibilityDescription: nil)
+ let symbolConfig = NSImage.SymbolConfiguration(pointSize: 9 * fontScale, weight: .bold)
+ chevronView.image = chevronImage?.withSymbolConfiguration(symbolConfig)
+ chevronView.translatesAutoresizingMaskIntoConstraints = false
+ chevronView.isHidden = !isCustomAgentEnabled
+
+ // Create title label
+ let titleLabel = NSTextField(labelWithString: title)
+ titleLabel.font = NSFont.systemFont(ofSize: 12 * fontScale)
+ titleLabel.isEditable = false
+ titleLabel.isBordered = false
+ titleLabel.backgroundColor = .clear
+ titleLabel.drawsBackground = false
+ titleLabel.translatesAutoresizingMaskIntoConstraints = false
+ titleLabel.setContentHuggingPriority(.required, for: .horizontal)
+ titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
+ titleLabel.alignment = .center
+ titleLabel.usesSingleLineMode = true
+ titleLabel.lineBreakMode = .byClipping
+
+ // Create horizontal stack with icon, title, and chevron
+ let stackView = NSStackView(views: [iconImageView, titleLabel, chevronView])
+ stackView.orientation = .horizontal
+ stackView.spacing = 0
+ stackView.translatesAutoresizingMaskIntoConstraints = false
+ stackView.alignment = .centerY
+ stackView.setHuggingPriority(.required, for: .horizontal)
+ stackView.setContentCompressionResistancePriority(.required, for: .horizontal)
+
+ // Set custom spacing between title and chevron
+ stackView.setCustomSpacing(3 * fontScale, after: titleLabel)
+
+ button.addSubview(stackView)
+ containerView.addSubview(button)
+
+ let stackLeadingConstraint = stackView.leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: 6 * fontScale)
+ let stackTrailingConstraint = stackView.trailingAnchor.constraint(equalTo: button.trailingAnchor, constant: -6 * fontScale)
+ let stackTopConstraint = stackView.topAnchor.constraint(equalTo: button.topAnchor, constant: 2 * fontScale)
+ let stackBottomConstraint = stackView.bottomAnchor.constraint(equalTo: button.bottomAnchor, constant: -2 * fontScale)
+ let iconWidthConstraint = iconImageView.widthAnchor.constraint(equalToConstant: 16 * fontScale)
+ let iconHeightConstraint = iconImageView.heightAnchor.constraint(equalToConstant: 16 * fontScale)
+ let chevronWidthConstraint = chevronView.widthAnchor.constraint(equalToConstant: 9 * fontScale)
+ let chevronHeightConstraint = chevronView.heightAnchor.constraint(equalToConstant: 9 * fontScale)
+
+ NSLayoutConstraint.activate([
+ button.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
+ button.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
+ button.topAnchor.constraint(equalTo: containerView.topAnchor),
+ button.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
+
+ stackLeadingConstraint,
+ stackTrailingConstraint,
+ stackTopConstraint,
+ stackBottomConstraint,
+
+ iconWidthConstraint,
+ iconHeightConstraint,
+
+ chevronWidthConstraint,
+ chevronHeightConstraint,
+ ])
+
+ context.coordinator.button = button
+ context.coordinator.titleLabel = titleLabel
+ context.coordinator.iconImageView = iconImageView
+ context.coordinator.chevronView = chevronView
+ context.coordinator.stackView = stackView
+ context.coordinator.stackLeadingConstraint = stackLeadingConstraint
+ context.coordinator.stackTrailingConstraint = stackTrailingConstraint
+ context.coordinator.stackTopConstraint = stackTopConstraint
+ context.coordinator.stackBottomConstraint = stackBottomConstraint
+ context.coordinator.iconWidthConstraint = iconWidthConstraint
+ context.coordinator.iconHeightConstraint = iconHeightConstraint
+ context.coordinator.chevronWidthConstraint = chevronWidthConstraint
+ context.coordinator.chevronHeightConstraint = chevronHeightConstraint
+
+ return containerView
+ }
+
+ func updateNSView(_ nsView: NSView, context: Context) {
+ guard let button = context.coordinator.button,
+ let titleLabel = context.coordinator.titleLabel,
+ let iconImageView = context.coordinator.iconImageView,
+ let chevronView = context.coordinator.chevronView,
+ let stackView = context.coordinator.stackView else { return }
+
+ titleLabel.stringValue = title
+ titleLabel.font = NSFont.systemFont(ofSize: 12 * fontScale)
+ context.coordinator.chatMode = chatMode
+ context.coordinator.builtInAgentModes = builtInAgentModes
+ context.coordinator.customAgents = customAgents
+ context.coordinator.selectedAgent = selectedAgent
+ context.coordinator.isSelected = isSelected
+ context.coordinator.isCustomAgentEnabled = isCustomAgentEnabled
+ context.coordinator.fontScale = fontScale
+
+ // Update constraints for scaling
+ context.coordinator.stackLeadingConstraint?.constant = 6 * fontScale
+ context.coordinator.stackTrailingConstraint?.constant = -6 * fontScale
+ context.coordinator.stackTopConstraint?.constant = 2 * fontScale
+ context.coordinator.stackBottomConstraint?.constant = -2 * fontScale
+ context.coordinator.iconWidthConstraint?.constant = 16 * fontScale
+ context.coordinator.iconHeightConstraint?.constant = 16 * fontScale
+ context.coordinator.chevronWidthConstraint?.constant = 9 * fontScale
+ context.coordinator.chevronHeightConstraint?.constant = 9 * fontScale
+ stackView.spacing = 0
+
+ // Update custom spacing between title and chevron
+ stackView.setCustomSpacing(3 * fontScale, after: titleLabel)
+
+ // Update chevron visibility based on feature flag and policy
+ chevronView.isHidden = !isCustomAgentEnabled
+
+ // Update icon based on selected agent mode
+ if let iconName = selectedIconName {
+ iconImageView.isHidden = false
+ iconImageView.image = createIconImage(named: iconName, pointSize: 16 * fontScale)
+ } else {
+ // No icon for custom agents
+ iconImageView.isHidden = true
+ iconImageView.image = nil
+ }
+
+ // Update chevron icon with scaled size
+ chevronView.image = createSFSymbolImage(named: "chevron.down", pointSize: 9 * fontScale, weight: .bold)
+
+ // Update button appearance based on selection
+ if isSelected {
+ button.layer?.backgroundColor = NSColor(activeBackground).cgColor
+ titleLabel.textColor = NSColor(activeTextColor)
+ iconImageView.contentTintColor = NSColor(activeTextColor)
+ chevronView.contentTintColor = NSColor(activeTextColor)
+
+ // Remove existing shadows before adding new ones
+ button.layer?.shadowOpacity = 0
+
+ // Add shadows
+ button.shadow = {
+ let shadow = NSShadow()
+ shadow.shadowColor = NSColor.black.withAlphaComponent(0.05)
+ shadow.shadowOffset = NSSize(width: 0, height: -1)
+ shadow.shadowBlurRadius = 0.375
+ return shadow
+ }()
+
+ // For the second shadow, we can add a sublayer or just use one.
+ // For simplicity, we will just use one for now. A second shadow can be added with a sublayer if needed.
+
+ // Add overlay
+ button.layer?.borderColor = NSColor.black.withAlphaComponent(0.02).cgColor
+ button.layer?.borderWidth = 0.5
+
+ } else {
+ button.layer?.backgroundColor = NSColor.clear.cgColor
+ titleLabel.textColor = NSColor(inactiveTextColor)
+ iconImageView.contentTintColor = NSColor(inactiveTextColor)
+ chevronView.contentTintColor = NSColor(inactiveTextColor)
+ button.shadow = nil
+ button.layer?.borderColor = NSColor.clear.cgColor
+ button.layer?.borderWidth = 0
+ }
+ button.wantsLayer = true
+ button.layer?.cornerRadius = 10 * fontScale
+ button.layer?.cornerCurve = .continuous
+ }
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator(
+ chatMode: chatMode,
+ builtInAgentModes: builtInAgentModes,
+ customAgents: customAgents,
+ selectedAgent: selectedAgent,
+ isSelected: isSelected,
+ isCustomAgentEnabled: isCustomAgentEnabled,
+ fontScale: fontScale,
+ onSelectAgent: onSelectAgent,
+ onEditAgent: onEditAgent,
+ onDeleteAgent: onDeleteAgent,
+ onCreateAgent: onCreateAgent
+ )
+ }
+
+ // MARK: - Helper Methods for Image Creation
+
+ /// Creates an icon image - either a custom asset or SF Symbol
+ private func createIconImage(named iconName: String, pointSize: CGFloat) -> NSImage? {
+ if iconName == AgentModeIcon.agent {
+ return createResizedCustomImage(named: iconName, targetSize: pointSize)
+ } else {
+ return createSFSymbolImage(named: iconName, pointSize: pointSize, weight: .bold)
+ }
+ }
+
+ /// Creates a resized custom image (non-SF Symbol) with template rendering
+ private func createResizedCustomImage(named imageName: String, targetSize: CGFloat) -> NSImage? {
+ guard let image = NSImage(named: imageName) else { return nil }
+
+ let size = NSSize(width: targetSize, height: targetSize)
+ let resizedImage = NSImage(size: size)
+ resizedImage.lockFocus()
+ NSGraphicsContext.current?.imageInterpolation = .high
+ image.draw(
+ in: NSRect(origin: .zero, size: size),
+ from: NSRect(origin: .zero, size: image.size),
+ operation: .sourceOver,
+ fraction: 1.0
+ )
+ resizedImage.unlockFocus()
+ resizedImage.isTemplate = true
+ return resizedImage
+ }
+
+ /// Creates an SF Symbol image with the specified configuration
+ private func createSFSymbolImage(named symbolName: String, pointSize: CGFloat, weight: NSFont.Weight) -> NSImage? {
+ let config = NSImage.SymbolConfiguration(pointSize: pointSize, weight: weight)
+ return NSImage(systemSymbolName: symbolName, accessibilityDescription: nil)?
+ .withSymbolConfiguration(config)
+ }
+
+ class Coordinator: NSObject {
+ var chatMode: String
+ var builtInAgentModes: [ConversationMode]
+ var customAgents: [ConversationMode]
+ var selectedAgent: ConversationMode
+ var isSelected: Bool
+ var isCustomAgentEnabled: Bool
+ var fontScale: Double
+ var button: NSButton?
+ var titleLabel: NSTextField?
+ var iconImageView: NSImageView?
+ var chevronView: NSImageView?
+ var stackView: NSStackView?
+ var stackLeadingConstraint: NSLayoutConstraint?
+ var stackTrailingConstraint: NSLayoutConstraint?
+ var stackTopConstraint: NSLayoutConstraint?
+ var stackBottomConstraint: NSLayoutConstraint?
+ var iconWidthConstraint: NSLayoutConstraint?
+ var iconHeightConstraint: NSLayoutConstraint?
+ var chevronWidthConstraint: NSLayoutConstraint?
+ var chevronHeightConstraint: NSLayoutConstraint?
+ let onSelectAgent: (ConversationMode) -> Void
+ let onEditAgent: (ConversationMode) -> Void
+ let onDeleteAgent: (ConversationMode) -> Void
+ let onCreateAgent: () -> Void
+
+ init(
+ chatMode: String,
+ builtInAgentModes: [ConversationMode],
+ customAgents: [ConversationMode],
+ selectedAgent: ConversationMode,
+ isSelected: Bool,
+ isCustomAgentEnabled: Bool,
+ fontScale: Double,
+ onSelectAgent: @escaping (ConversationMode) -> Void,
+ onEditAgent: @escaping (ConversationMode) -> Void,
+ onDeleteAgent: @escaping (ConversationMode) -> Void,
+ onCreateAgent: @escaping () -> Void
+ ) {
+ self.chatMode = chatMode
+ self.builtInAgentModes = builtInAgentModes
+ self.customAgents = customAgents
+ self.selectedAgent = selectedAgent
+ self.isSelected = isSelected
+ self.isCustomAgentEnabled = isCustomAgentEnabled
+ self.fontScale = fontScale
+ self.onSelectAgent = onSelectAgent
+ self.onEditAgent = onEditAgent
+ self.onDeleteAgent = onDeleteAgent
+ self.onCreateAgent = onCreateAgent
+ }
+
+ @objc func buttonClicked(_ sender: NSButton) {
+ // If in Ask mode, switch to agent mode
+ if chatMode == ChatMode.Ask.rawValue {
+ // Restore the previously selected agent from AppState
+ let savedSubMode = AppState.shared.getSelectedAgentSubMode()
+
+ // Try to find the saved agent
+ let agent = builtInAgentModes.first(where: { $0.id == savedSubMode })
+ ?? customAgents.first(where: { $0.id == savedSubMode })
+ ?? builtInAgentModes.first
+
+ if let agent = agent {
+ onSelectAgent(agent)
+ }
+ } else {
+ // If in Agent mode and custom agent is enabled, show the menu
+ // If custom agent is disabled, do nothing
+ if isCustomAgentEnabled {
+ showMenu(sender)
+ }
+ }
+ }
+
+ @objc func showMenu(_ sender: NSButton) {
+ let menuBuilder = AgentModeMenu(
+ builtInAgentModes: builtInAgentModes,
+ customAgents: customAgents,
+ selectedAgent: selectedAgent,
+ fontScale: fontScale,
+ onSelectAgent: onSelectAgent,
+ onEditAgent: onEditAgent,
+ onDeleteAgent: onDeleteAgent,
+ onCreateAgent: onCreateAgent
+ )
+ menuBuilder.showMenu(relativeTo: sender)
+ }
+ }
+}
diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeButtonMenuItem.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeButtonMenuItem.swift
new file mode 100644
index 00000000..5ed180f8
--- /dev/null
+++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeButtonMenuItem.swift
@@ -0,0 +1,506 @@
+import AppKit
+import ConversationServiceProvider
+import SwiftUI
+
+// MARK: - Agent Menu Item View
+
+class AgentModeButtonMenuItem: NSView {
+ // Layout constants
+ private let fontScale: Double
+
+ private lazy var scaledConstants = ScaledLayoutConstants(fontScale: fontScale)
+
+ private struct ScaledLayoutConstants {
+ let fontScale: Double
+
+ var menuHeight: CGFloat { 22 * fontScale }
+ var checkmarkLeftEdge: CGFloat { 9 * fontScale }
+ var checkmarkSize: CGFloat { 13 * fontScale }
+ var iconSize: CGFloat { 16 * fontScale }
+ var iconTextSpacing: CGFloat { 5 * fontScale }
+ var checkmarkIconSpacing: CGFloat { 5 * fontScale }
+ var hoverEdgeInset: CGFloat { 5 * fontScale }
+ var buttonSpacing: CGFloat { -4 * fontScale }
+ var deleteButtonRightEdge: CGFloat { 12 * fontScale }
+ var buttonSize: CGFloat { 24 * fontScale }
+ var buttonIconSize: CGFloat { 10 * fontScale }
+ var buttonBackgroundSize: CGFloat { 17 * fontScale }
+ var buttonBackgroundEdgeInset: CGFloat { 3 * fontScale }
+ var minWidth: CGFloat { 180 * fontScale }
+ var maxWidth: CGFloat { 320 * fontScale }
+ var fontSize: CGFloat { 13 * fontScale }
+ var fontWeight: NSFont.Weight { .regular }
+
+ // MARK: - Computed Properties for Repeated Calculations
+
+ /// Starting X position for checkmark and icons without selection
+ var checkmarkStartX: CGFloat { checkmarkLeftEdge }
+
+ /// Starting X position for icons when menu has selection
+ var iconStartXWithSelection: CGFloat {
+ checkmarkLeftEdge + checkmarkSize + checkmarkIconSpacing
+ }
+
+ /// Icon X position based on selection state
+ func iconX(isSelected: Bool, menuHasSelection: Bool) -> CGFloat {
+ isSelected || menuHasSelection ? iconStartXWithSelection : checkmarkLeftEdge
+ }
+
+ /// Helper to vertically center an element within the menu height
+ func centeredY(for elementSize: CGFloat) -> CGFloat {
+ (menuHeight - elementSize) / 2
+ }
+
+ /// Starting X position for label text based on icon presence
+ func labelStartX(hasIcon: Bool, iconName: String?, isSelected: Bool, menuHasSelection: Bool) -> CGFloat {
+ if hasIcon {
+ let iconX: CGFloat
+ let iconWidth: CGFloat
+ if iconName == AgentModeIcon.plus {
+ iconX = checkmarkLeftEdge
+ iconWidth = checkmarkSize
+ } else {
+ iconX = isSelected ? iconStartXWithSelection : (menuHasSelection ? iconStartXWithSelection : checkmarkLeftEdge)
+ iconWidth = iconSize
+ }
+ return iconX + iconWidth + iconTextSpacing
+ } else {
+ return menuHasSelection ? iconStartXWithSelection : checkmarkLeftEdge
+ }
+ }
+ }
+
+ private let name: String
+ private let iconName: String?
+ private let isSelected: Bool
+ private let menuHasSelection: Bool
+ private let onSelect: () -> Void
+ private let onEdit: (() -> Void)?
+ private let onDelete: (() -> Void)?
+
+ private var isHovered = false
+ private var isEditButtonHovered = false
+ private var isDeleteButtonHovered = false
+ private var trackingArea: NSTrackingArea?
+
+ private var hasEditDeleteButtons: Bool {
+ onEdit != nil && onDelete != nil
+ }
+
+ private let nameLabel = NSTextField(labelWithString: "")
+ private let iconImageView = NSImageView()
+ private let checkmarkImageView = NSImageView()
+ private let editButton = NSButton()
+ private let deleteButton = NSButton()
+ private let editButtonBackground = NSView()
+ private let deleteButtonBackground = NSView()
+
+ init(
+ name: String,
+ iconName: String?,
+ isSelected: Bool,
+ menuHasSelection: Bool,
+ fontScale: Double = 1.0,
+ fixedWidth: CGFloat? = nil,
+ onSelect: @escaping () -> Void,
+ onEdit: (() -> Void)? = nil,
+ onDelete: (() -> Void)? = nil
+ ) {
+ self.name = name
+ self.iconName = iconName
+ self.isSelected = isSelected
+ self.menuHasSelection = menuHasSelection
+ self.fontScale = fontScale
+ self.onSelect = onSelect
+ self.onEdit = onEdit
+ self.onDelete = onDelete
+
+ // Use fixed width if provided, otherwise calculate dynamically
+ let calculatedWidth = fixedWidth ?? Self.calculateMenuItemWidth(
+ name: name,
+ hasIcon: iconName != nil,
+ isSelected: isSelected,
+ menuHasSelection: menuHasSelection,
+ hasEditDelete: onEdit != nil && onDelete != nil,
+ fontScale: fontScale
+ )
+
+ let constants = ScaledLayoutConstants(fontScale: fontScale)
+ super.init(frame: NSRect(x: 0, y: 0, width: calculatedWidth, height: constants.menuHeight))
+ setupView()
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ static func calculateMenuItemWidth(
+ name: String,
+ hasIcon: Bool,
+ isSelected: Bool,
+ menuHasSelection: Bool,
+ hasEditDelete: Bool,
+ fontScale: Double = 1.0
+ ) -> CGFloat {
+ // Create scaled constants
+ let constants = ScaledLayoutConstants(fontScale: fontScale)
+
+ // Calculate text width
+ let font = NSFont.systemFont(ofSize: constants.fontSize, weight: constants.fontWeight)
+ let textAttributes = [NSAttributedString.Key.font: font]
+ let textSize = (name as NSString).size(withAttributes: textAttributes)
+
+ // Calculate label X position using computed property
+ let iconName = hasIcon ? (name == "Create an agent" ? AgentModeIcon.plus : nil) : nil
+ let labelX = constants.labelStartX(hasIcon: hasIcon, iconName: iconName, isSelected: isSelected, menuHasSelection: menuHasSelection)
+
+ // Calculate required width
+ var width = labelX + textSize.width + 10 * fontScale // 10pt padding after text
+
+ if hasEditDelete {
+ // Add space for edit and delete buttons
+ width = max(width, labelX + textSize.width + 20 * fontScale) // Ensure some space before buttons
+ width += (constants.buttonSize * 2) + constants.buttonSpacing + constants.deleteButtonRightEdge
+ } else {
+ width += 10 * fontScale // Extra padding for items without buttons
+ }
+
+ // Clamp to min/max width
+ return min(max(width, constants.minWidth), constants.maxWidth)
+ }
+
+ private func setupView() {
+ wantsLayer = true
+ layer?.masksToBounds = true
+
+ setupCheckmark()
+ setupIcon()
+ setupNameLabel()
+
+ let showEditDeleteButtons = onEdit != nil && onDelete != nil
+ if showEditDeleteButtons {
+ setupEditDeleteButtons()
+ }
+
+ setupTrackingArea()
+ }
+
+ // MARK: - View Setup Helpers
+
+ private func setupCheckmark() {
+ let checkmarkConfig = NSImage.SymbolConfiguration(pointSize: scaledConstants.checkmarkSize, weight: .medium)
+ if let image = NSImage(systemSymbolName: "checkmark", accessibilityDescription: nil)?
+ .withSymbolConfiguration(checkmarkConfig) {
+ checkmarkImageView.image = image
+ }
+ checkmarkImageView.contentTintColor = .labelColor
+ let checkmarkY = scaledConstants.centeredY(for: scaledConstants.checkmarkSize)
+ checkmarkImageView.frame = NSRect(
+ x: scaledConstants.checkmarkStartX,
+ y: checkmarkY,
+ width: scaledConstants.checkmarkSize,
+ height: scaledConstants.checkmarkSize
+ )
+ checkmarkImageView.isHidden = !isSelected
+ addSubview(checkmarkImageView)
+ }
+
+ private func setupIcon() {
+ guard let iconName = iconName else { return }
+
+ if iconName == AgentModeIcon.agent {
+ setupCustomAgentIcon()
+ } else if iconName == AgentModeIcon.plus {
+ setupPlusIcon()
+ } else {
+ setupSFSymbolIcon(iconName)
+ }
+
+ iconImageView.contentTintColor = .labelColor
+ iconImageView.isHidden = false
+
+ // Calculate and set icon position
+ let (iconX, iconSize, iconY) = calculateIconPosition(for: iconName)
+ iconImageView.frame = NSRect(x: iconX, y: iconY, width: iconSize, height: iconSize)
+ addSubview(iconImageView)
+ }
+
+ private func setupCustomAgentIcon() {
+ guard let image = NSImage(named: AgentModeIcon.agent) else { return }
+
+ let targetSize = NSSize(width: scaledConstants.iconSize, height: scaledConstants.iconSize)
+ let resizedImage = NSImage(size: targetSize)
+ resizedImage.lockFocus()
+ NSGraphicsContext.current?.imageInterpolation = .high
+ image.draw(
+ in: NSRect(origin: .zero, size: targetSize),
+ from: NSRect(origin: .zero, size: image.size),
+ operation: .sourceOver,
+ fraction: 1.0
+ )
+ resizedImage.unlockFocus()
+ resizedImage.isTemplate = true
+ iconImageView.image = resizedImage
+ }
+
+ private func setupPlusIcon() {
+ let plusConfig = NSImage.SymbolConfiguration(pointSize: scaledConstants.checkmarkSize, weight: .medium)
+ if let image = NSImage(systemSymbolName: AgentModeIcon.plus, accessibilityDescription: nil) {
+ iconImageView.image = image.withSymbolConfiguration(plusConfig)
+ }
+ }
+
+ private func setupSFSymbolIcon(_ iconName: String) {
+ let symbolConfig = NSImage.SymbolConfiguration(pointSize: scaledConstants.iconSize, weight: .medium)
+ if let image = NSImage(systemSymbolName: iconName, accessibilityDescription: nil) {
+ iconImageView.image = image.withSymbolConfiguration(symbolConfig)
+ }
+ }
+
+ private func calculateIconPosition(for iconName: String) -> (x: CGFloat, size: CGFloat, y: CGFloat) {
+ if iconName == AgentModeIcon.plus {
+ let size = scaledConstants.checkmarkSize
+ return (
+ scaledConstants.checkmarkStartX,
+ size,
+ scaledConstants.centeredY(for: size)
+ )
+ } else {
+ let size = scaledConstants.iconSize
+ return (
+ scaledConstants.iconX(isSelected: isSelected, menuHasSelection: menuHasSelection),
+ size,
+ scaledConstants.centeredY(for: size)
+ )
+ }
+ }
+
+ private func setupNameLabel() {
+ let labelX = scaledConstants.labelStartX(
+ hasIcon: iconName != nil,
+ iconName: iconName,
+ isSelected: isSelected,
+ menuHasSelection: menuHasSelection
+ )
+
+ nameLabel.stringValue = name
+ nameLabel.font = NSFont.systemFont(ofSize: scaledConstants.fontSize, weight: scaledConstants.fontWeight)
+ nameLabel.textColor = .labelColor
+ nameLabel.frame = NSRect(x: labelX, y: 3 * fontScale, width: 160 * fontScale, height: 16 * fontScale)
+ nameLabel.isEditable = false
+ nameLabel.isBordered = false
+ nameLabel.backgroundColor = .clear
+ nameLabel.drawsBackground = false
+ addSubview(nameLabel)
+ }
+
+ private func setupEditDeleteButtons() {
+ let viewWidth = frame.width
+ let buttonIconConfig = NSImage.SymbolConfiguration(pointSize: scaledConstants.buttonIconSize, weight: .medium)
+
+ // Calculate button positions from the right edge
+ let deleteButtonX = viewWidth - scaledConstants.deleteButtonRightEdge - scaledConstants.buttonSize
+ let editButtonX = deleteButtonX - scaledConstants.buttonSpacing - scaledConstants.buttonSize
+ let backgroundY = (frame.height - scaledConstants.buttonBackgroundSize) / 2
+
+ // Setup edit button and background
+ setupEditButton(at: editButtonX, backgroundY: backgroundY, config: buttonIconConfig)
+
+ // Setup delete button and background
+ setupDeleteButton(at: deleteButtonX, backgroundY: backgroundY, config: buttonIconConfig)
+ }
+
+ private func setupButtonWithBackground(
+ button: NSButton,
+ background: NSView,
+ at x: CGFloat,
+ backgroundY: CGFloat,
+ iconName: String,
+ accessibilityDescription: String,
+ action: Selector,
+ config: NSImage.SymbolConfiguration
+ ) {
+ // Setup background
+ let backgroundX = x + scaledConstants.buttonBackgroundEdgeInset
+ background.wantsLayer = true
+ background.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.15).cgColor
+ background.layer?.cornerRadius = scaledConstants.buttonBackgroundSize / 2
+ background.frame = NSRect(
+ x: backgroundX,
+ y: backgroundY,
+ width: scaledConstants.buttonBackgroundSize,
+ height: scaledConstants.buttonBackgroundSize
+ )
+ background.isHidden = true
+ addSubview(background)
+
+ // Setup button
+ button.image = NSImage(systemSymbolName: iconName, accessibilityDescription: accessibilityDescription)?.withSymbolConfiguration(config)
+ button.bezelStyle = .roundRect
+ button.isBordered = false
+ button.frame = NSRect(
+ x: x,
+ y: scaledConstants.centeredY(for: scaledConstants.buttonSize),
+ width: scaledConstants.buttonSize,
+ height: scaledConstants.buttonSize
+ )
+ button.target = self
+ button.action = action
+ button.isHidden = true
+ button.alphaValue = 1.0
+ addSubview(button)
+ }
+
+ private func setupEditButton(at x: CGFloat, backgroundY: CGFloat, config: NSImage.SymbolConfiguration) {
+ setupButtonWithBackground(
+ button: editButton,
+ background: editButtonBackground,
+ at: x,
+ backgroundY: backgroundY,
+ iconName: "pencil",
+ accessibilityDescription: "Edit",
+ action: #selector(editTapped),
+ config: config
+ )
+ }
+
+ private func setupDeleteButton(at x: CGFloat, backgroundY: CGFloat, config: NSImage.SymbolConfiguration) {
+ setupButtonWithBackground(
+ button: deleteButton,
+ background: deleteButtonBackground,
+ at: x,
+ backgroundY: backgroundY,
+ iconName: "trash",
+ accessibilityDescription: "Delete",
+ action: #selector(deleteTapped),
+ config: config
+ )
+ }
+
+ private func setupTrackingArea() {
+ // Use .zero rect with .inVisibleRect to automatically track the visible bounds
+ // This avoids accessing bounds during layout cycles
+ trackingArea = NSTrackingArea(
+ rect: .zero,
+ options: [.mouseEnteredAndExited, .mouseMoved, .activeInActiveApp, .inVisibleRect],
+ owner: self,
+ userInfo: nil
+ )
+ addTrackingArea(trackingArea!)
+ }
+
+ override func mouseEntered(with event: NSEvent) {
+ isHovered = true
+ updateButtonVisibility()
+ updateColors()
+ needsDisplay = true
+ }
+
+ override func mouseExited(with event: NSEvent) {
+ isHovered = false
+ isEditButtonHovered = false
+ isDeleteButtonHovered = false
+ updateButtonVisibility()
+ editButtonBackground.isHidden = true
+ deleteButtonBackground.isHidden = true
+ updateColors()
+ needsDisplay = true
+ }
+
+ override func mouseUp(with event: NSEvent) {
+ let location = convert(event.locationInWindow, from: nil)
+
+ if hasEditDeleteButtons {
+ if editButton.frame.contains(location) || deleteButton.frame.contains(location) {
+ return
+ }
+ }
+
+ onSelect()
+ }
+
+ override func updateTrackingAreas() {
+ super.updateTrackingAreas()
+ if let trackingArea = trackingArea {
+ removeTrackingArea(trackingArea)
+ }
+ setupTrackingArea()
+ }
+
+ private func updateButtonVisibility() {
+ if hasEditDeleteButtons {
+ editButton.isHidden = !isHovered
+ deleteButton.isHidden = !isHovered
+ }
+ }
+
+ private func updateColors() {
+ if isHovered {
+ nameLabel.textColor = .white
+ iconImageView.contentTintColor = .white
+ checkmarkImageView.contentTintColor = .white
+ if hasEditDeleteButtons {
+ editButton.contentTintColor = .white
+ deleteButton.contentTintColor = .white
+ }
+ } else {
+ nameLabel.textColor = .labelColor
+ iconImageView.contentTintColor = .labelColor
+ checkmarkImageView.contentTintColor = .labelColor
+ if hasEditDeleteButtons {
+ editButton.contentTintColor = nil
+ deleteButton.contentTintColor = nil
+ }
+ }
+ }
+
+ @objc private func editTapped() {
+ onEdit?()
+ }
+
+ @objc private func deleteTapped() {
+ onDelete?()
+ }
+
+ override func draw(_ dirtyRect: NSRect) {
+ super.draw(dirtyRect)
+
+ if isHovered {
+ ModelMenuItemFormatter.drawMenuItemHighlight(
+ in: frame,
+ fontScale: fontScale,
+ hoverEdgeInset: scaledConstants.hoverEdgeInset
+ )
+ }
+ }
+
+ override func mouseMoved(with event: NSEvent) {
+ guard hasEditDeleteButtons else { return }
+
+ let location = convert(event.locationInWindow, from: nil)
+
+ if editButton.frame.contains(location) && !editButton.isHidden {
+ updateButtonHoverState(editHovered: true, deleteHovered: false, trashFilled: false)
+ } else if deleteButton.frame.contains(location) && !deleteButton.isHidden {
+ updateButtonHoverState(editHovered: false, deleteHovered: true, trashFilled: true)
+ } else {
+ updateButtonHoverState(editHovered: false, deleteHovered: false, trashFilled: false)
+ }
+
+ if isHovered {
+ editButton.contentTintColor = .white
+ deleteButton.contentTintColor = .white
+ }
+ }
+
+ private func updateButtonHoverState(editHovered: Bool, deleteHovered: Bool, trashFilled: Bool) {
+ isEditButtonHovered = editHovered
+ isDeleteButtonHovered = deleteHovered
+ editButtonBackground.isHidden = !editHovered
+ deleteButtonBackground.isHidden = !deleteHovered
+
+ let buttonIconConfig = NSImage.SymbolConfiguration(pointSize: scaledConstants.buttonIconSize, weight: .medium)
+ let trashIcon = trashFilled ? "trash.fill" : "trash"
+ deleteButton.image = NSImage(systemSymbolName: trashIcon, accessibilityDescription: "Delete")?.withSymbolConfiguration(buttonIconConfig)
+ }
+}
diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeIconConstants.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeIconConstants.swift
new file mode 100644
index 00000000..3461a0f4
--- /dev/null
+++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeIconConstants.swift
@@ -0,0 +1,21 @@
+import Foundation
+
+// MARK: - Agent Mode Icon Constants
+
+enum AgentModeIcon {
+ /// Icon for Plan mode (SF Symbol: checklist)
+ static let plan = "checklist"
+
+ /// Icon for Agent mode (Custom asset: Agent)
+ static let agent = "Agent"
+
+ /// Icon for create/add actions (SF Symbol: plus)
+ static let plus = "plus"
+
+ /// Returns the appropriate icon name for a given agent mode name
+ /// - Parameter modeName: The name of the agent mode
+ /// - Returns: The icon name to use, or nil for custom agents
+ static func icon(for modeName: String) -> String {
+ return modeName.lowercased() == "plan" ? plan : agent
+ }
+}
diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeMenu.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeMenu.swift
new file mode 100644
index 00000000..81e76aef
--- /dev/null
+++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/AgentModeMenu.swift
@@ -0,0 +1,165 @@
+import AppKit
+import ConversationServiceProvider
+
+// MARK: - Agent Mode Menu Builder
+
+struct AgentModeMenu {
+ let builtInAgentModes: [ConversationMode]
+ let customAgents: [ConversationMode]
+ let selectedAgent: ConversationMode
+ let fontScale: Double
+ let onSelectAgent: (ConversationMode) -> Void
+ let onEditAgent: (ConversationMode) -> Void
+ let onDeleteAgent: (ConversationMode) -> Void
+ let onCreateAgent: () -> Void
+
+ func createMenu() -> NSMenu {
+ let menu = NSMenu()
+
+ let menuHasSelection = true // Always show checkmarks for clarity
+
+ // Calculate the maximum width needed across all items
+ let maxWidth = calculateMaxMenuItemWidth(menuHasSelection: menuHasSelection)
+
+ // Add built-in agent modes
+ addBuiltInModes(to: menu, menuHasSelection: menuHasSelection, width: maxWidth)
+
+ // Add custom agents if any
+ if !customAgents.isEmpty {
+ menu.addItem(.separator())
+ addCustomAgents(to: menu, menuHasSelection: menuHasSelection, width: maxWidth)
+ }
+
+ // Add create option
+ menu.addItem(.separator())
+ addCreateOption(to: menu, menuHasSelection: menuHasSelection, width: maxWidth)
+
+ return menu
+ }
+
+ private func calculateMaxMenuItemWidth(menuHasSelection: Bool) -> CGFloat {
+ var maxWidth: CGFloat = 0
+
+ // Check built-in modes
+ for mode in builtInAgentModes {
+ let width = AgentModeButtonMenuItem.calculateMenuItemWidth(
+ name: mode.name,
+ hasIcon: true,
+ isSelected: selectedAgent.id == mode.id,
+ menuHasSelection: menuHasSelection,
+ hasEditDelete: false,
+ fontScale: fontScale
+ )
+ maxWidth = max(maxWidth, width)
+ }
+
+ // Check custom agents
+ for agent in customAgents {
+ let width = AgentModeButtonMenuItem.calculateMenuItemWidth(
+ name: agent.name,
+ hasIcon: false,
+ isSelected: selectedAgent.id == agent.id,
+ menuHasSelection: menuHasSelection,
+ hasEditDelete: true,
+ fontScale: fontScale
+ )
+ maxWidth = max(maxWidth, width)
+ }
+
+ // Check create option
+ let createWidth = AgentModeButtonMenuItem.calculateMenuItemWidth(
+ name: "Create an agent",
+ hasIcon: true,
+ isSelected: false,
+ menuHasSelection: menuHasSelection,
+ hasEditDelete: false,
+ fontScale: fontScale
+ )
+ maxWidth = max(maxWidth, createWidth)
+
+ return maxWidth
+ }
+
+ private func addBuiltInModes(to menu: NSMenu, menuHasSelection: Bool, width: CGFloat) {
+ for mode in builtInAgentModes {
+ let agentItem = NSMenuItem()
+ // Determine icon: use checklist for Plan, Agent icon for others
+ let iconName = AgentModeIcon.icon(for: mode.name)
+ let agentView = AgentModeButtonMenuItem(
+ name: mode.name,
+ iconName: iconName,
+ isSelected: selectedAgent.id == mode.id,
+ menuHasSelection: menuHasSelection,
+ fontScale: fontScale,
+ fixedWidth: width,
+ onSelect: { [onSelectAgent] in
+ onSelectAgent(mode)
+ menu.cancelTracking()
+ }
+ )
+ agentView.toolTip = mode.description
+ agentItem.view = agentView
+ menu.addItem(agentItem)
+ }
+ }
+
+ private func addCustomAgents(to menu: NSMenu, menuHasSelection: Bool, width: CGFloat) {
+ for agent in customAgents {
+ let agentItem = NSMenuItem()
+ agentItem.representedObject = agent
+
+ // Create custom view for the menu item
+ let customView = AgentModeButtonMenuItem(
+ name: agent.name,
+ iconName: nil,
+ isSelected: selectedAgent.id == agent.id,
+ menuHasSelection: menuHasSelection,
+ fontScale: fontScale,
+ fixedWidth: width,
+ onSelect: { [onSelectAgent] in
+ onSelectAgent(agent)
+ menu.cancelTracking()
+ },
+ onEdit: { [onEditAgent] in
+ onEditAgent(agent)
+ menu.cancelTracking()
+ },
+ onDelete: { [onDeleteAgent] in
+ onDeleteAgent(agent)
+ menu.cancelTracking()
+ }
+ )
+
+ customView.toolTip = agent.description
+ agentItem.view = customView
+ menu.addItem(agentItem)
+ }
+ }
+
+ private func addCreateOption(to menu: NSMenu, menuHasSelection: Bool, width: CGFloat) {
+ let createItem = NSMenuItem()
+ let createView = AgentModeButtonMenuItem(
+ name: "Create an agent",
+ iconName: AgentModeIcon.plus,
+ isSelected: false,
+ menuHasSelection: menuHasSelection,
+ fontScale: fontScale,
+ fixedWidth: width,
+ onSelect: { [onCreateAgent] in
+ onCreateAgent()
+ menu.cancelTracking()
+ }
+ )
+ createItem.view = createView
+ menu.addItem(createItem)
+ }
+
+ func showMenu(relativeTo button: NSButton) {
+ let menu = createMenu()
+
+ // Show menu aligned to the button's edge, positioned below the button
+ let buttonFrame = button.frame
+ let menuOrigin = NSPoint(x: buttonFrame.minX, y: buttonFrame.maxY)
+ menu.popUp(positioning: menu.items.first, at: menuOrigin, in: button.superview)
+ }
+}
diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/ChatModePicker.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/ChatModePicker.swift
new file mode 100644
index 00000000..641a4489
--- /dev/null
+++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/ChatModePicker.swift
@@ -0,0 +1,294 @@
+import AppKit
+import AppKitExtension
+import ChatService
+import Combine
+import ConversationServiceProvider
+import GitHubCopilotService
+import Persist
+import SharedUIComponents
+import SwiftUI
+import SystemUtils
+import Workspace
+import XcodeInspector
+
+public extension Notification.Name {
+ static let gitHubCopilotChatModeDidChange = Notification
+ .Name("com.github.CopilotForXcode.ChatModeDidChange")
+}
+
+public struct ChatModePicker: View {
+ @Binding var chatMode: String
+ @Binding var selectedAgent: ConversationMode
+
+ let projectRootURL: URL?
+ @Environment(\.colorScheme) var colorScheme
+ @State var isAgentModeFFEnabled: Bool
+ @State var isCustomAgentPolicyEnabled: Bool
+ @State private var cancellables = Set()
+ @State private var builtInAgents: [ConversationMode] = []
+ @State private var customAgents: [ConversationMode] = []
+ @State private var isCreateSheetPresented = false
+ @State private var agentToDelete: ConversationMode?
+ @State private var showDeleteConfirmation = false
+ var onScopeChange: (PromptTemplateScope, String?) -> Void
+
+ public init(
+ projectRootURL: URL?,
+ chatMode: Binding,
+ selectedAgent: Binding,
+ onScopeChange: @escaping (PromptTemplateScope, String?) -> Void = { _, _ in }
+ ) {
+ _chatMode = chatMode
+ _selectedAgent = selectedAgent
+ self.projectRootURL = projectRootURL
+ self.onScopeChange = onScopeChange
+ isAgentModeFFEnabled = FeatureFlagNotifierImpl.shared.featureFlags.agentMode
+ isCustomAgentPolicyEnabled = CopilotPolicyNotifierImpl.shared.copilotPolicy.customAgentEnabled
+ }
+
+ private func setAskMode() {
+ chatMode = ChatMode.Ask.rawValue
+ AppState.shared.setSelectedChatMode(ChatMode.Ask.rawValue)
+ onScopeChange(.chatPanel, nil)
+ NotificationCenter.default.post(
+ name: .gitHubCopilotChatModeDidChange,
+ object: nil
+ )
+ }
+
+ private func setAgentMode(_ agent: ConversationMode) {
+ chatMode = ChatMode.Agent.rawValue
+ selectedAgent = agent
+ AppState.shared.setSelectedChatMode(ChatMode.Agent.rawValue)
+ AppState.shared.setSelectedAgentSubMode(agent.id)
+
+ // Load agents if switching from Ask mode
+ Task {
+ await loadCustomAgentsAsync()
+ }
+ onScopeChange(.agentPanel, agent.model)
+ NotificationCenter.default.post(
+ name: .gitHubCopilotChatModeDidChange,
+ object: nil
+ )
+ }
+
+ private func subscribeToFeatureFlagsDidChangeEvent() {
+ FeatureFlagNotifierImpl.shared.featureFlagsDidChange.sink(receiveValue: { featureFlags in
+ isAgentModeFFEnabled = featureFlags.agentMode
+ })
+ .store(in: &cancellables)
+ }
+
+ private func subscribeToPolicyDidChangeEvent() {
+ CopilotPolicyNotifierImpl.shared.policyDidChange.sink(receiveValue: { policy in
+ isCustomAgentPolicyEnabled = policy.customAgentEnabled
+ })
+ .store(in: &cancellables)
+ }
+
+ private func loadCustomAgents() {
+ Task {
+ await loadCustomAgentsAsync()
+
+ // Only restore if we're in Agent mode
+ if chatMode == ChatMode.Agent.rawValue {
+ loadSelectedAgentSubMode()
+ }
+ }
+ }
+
+ private func loadCustomAgentsAsync() async {
+ guard let modes = await SharedChatService.shared.loadConversationModes() else {
+ // Fallback: create default built-in modes when server returns nil
+ builtInAgents = [.defaultAgent]
+ customAgents = []
+ return
+ }
+
+ // Filter built-in modes (exclude Edit)
+ builtInAgents = modes.filter { $0.isBuiltIn && $0.kind == .Agent }
+
+ // Filter for custom agent modes (non-built-in)
+ customAgents = modes.filter { !$0.isBuiltIn && $0.kind == .Agent }
+ }
+
+ private func deleteCustomAgent(_ agent: ConversationMode) {
+ agentToDelete = agent
+ showDeleteConfirmation = true
+ }
+
+ private func performDelete() {
+ guard let agent = agentToDelete,
+ let uriString = agent.uri,
+ let fileURL = URL(string: uriString) else {
+ return
+ }
+
+ do {
+ try FileManager.default.removeItem(at: fileURL)
+ loadCustomAgents()
+ } catch {
+ // Error handling
+ }
+ agentToDelete = nil
+ }
+
+ private func openAgentFileInXcode(_ agent: ConversationMode) {
+ guard let uriString = agent.uri, let fileURL = URL(string: uriString) else {
+ return
+ }
+
+ NSWorkspace.openFileInXcode(fileURL: fileURL)
+ }
+
+ private func createNewAgent() {
+ isCreateSheetPresented = true
+ }
+
+ private var displayName: String {
+ return selectedAgent.name
+ }
+
+ private var displayIconName: String? {
+ // Custom agents don't have icons
+ if !selectedAgent.isBuiltIn {
+ return nil
+ }
+ // Use checklist icon for Plan, Agent icon for others
+ return AgentModeIcon.icon(for: selectedAgent.name)
+ }
+
+ public var body: some View {
+ VStack {
+ if isAgentModeFFEnabled {
+ HStack(spacing: -1) {
+ ModeButton(
+ title: "Ask",
+ isSelected: chatMode == ChatMode.Ask.rawValue,
+ activeBackground: colorScheme == .dark ? Color.white.opacity(0.25) : Color.white,
+ activeTextColor: Color.primary,
+ inactiveTextColor: Color.primary.opacity(0.5),
+ action: {
+ setAskMode()
+ }
+ )
+
+ AgentModeButton(
+ title: displayName,
+ isSelected: chatMode == ChatMode.Agent.rawValue,
+ activeBackground: Color.accentColor,
+ activeTextColor: Color.white,
+ inactiveTextColor: Color.primary.opacity(0.5),
+ chatMode: chatMode,
+ builtInAgentModes: builtInAgents,
+ customAgents: customAgents,
+ selectedAgent: selectedAgent,
+ selectedIconName: displayIconName,
+ isCustomAgentEnabled: isCustomAgentPolicyEnabled,
+ onSelectAgent: { setAgentMode($0) },
+ onEditAgent: { openAgentFileInXcode($0) },
+ onDeleteAgent: { deleteCustomAgent($0) },
+ onCreateAgent: { createNewAgent() }
+ )
+ }
+ .scaledPadding(1)
+ .scaledFrame(height: 22, alignment: .topLeading)
+ .background(.primary.opacity(0.1))
+ .cornerRadius(16)
+ .padding(4)
+ .help("Set Agent")
+ } else {
+ EmptyView()
+ }
+ }
+ .task {
+ subscribeToFeatureFlagsDidChangeEvent()
+ subscribeToPolicyDidChangeEvent()
+ await loadCustomAgentsAsync()
+ loadSelectedAgentSubMode()
+ if !isAgentModeFFEnabled {
+ setAskMode()
+ }
+ }
+ .onChange(of: isAgentModeFFEnabled) { newAgentModeFFEnabled in
+ if !newAgentModeFFEnabled {
+ setAskMode()
+ }
+ }
+ .onChange(of: isCustomAgentPolicyEnabled) { newValue in
+ // If custom agent policy is disabled and current agent is not the default agent, reset to default
+ if !newValue && chatMode == ChatMode.Agent.rawValue && !selectedAgent.isDefaultAgent {
+ let defaultAgent = builtInAgents.first(where: { $0.isDefaultAgent }) ?? .defaultAgent
+ setAgentMode(defaultAgent)
+ }
+ }
+ // Minimal refresh: when app becomes active (e.g. user returns from editing an agent file in Xcode)
+ // Reload custom agents to pick up external changes without adding complex file monitoring.
+ .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
+ loadCustomAgents()
+ }
+ .onChange(of: selectedAgent) { newAgent in
+ // When selectedAgent changes externally (e.g., from handoff),
+ // call setAgentMode to trigger all side effects
+ // Guard: only trigger if we're not already in the correct state to avoid redundant work
+ guard chatMode != ChatMode.Agent.rawValue ||
+ AppState.shared.getSelectedAgentSubMode() != newAgent.id else {
+ return
+ }
+ setAgentMode(newAgent)
+ }
+ .sheet(isPresented: $isCreateSheetPresented) {
+ CreateCustomCopilotFileView(
+ promptType: .agent,
+ editorPluginVersion: SystemUtils.editorPluginVersionString,
+ getCurrentProjectURL: { projectRootURL },
+ onSuccess: { _ in
+ loadCustomAgents()
+ },
+ onError: { _ in
+ // Handle error silently or log it
+ }
+ )
+ }
+ .confirmationDialog(
+ // `agentToDelete` should always be non-nil, adding fallback for compilation safety
+ "Are you sure you want to delete '\(agentToDelete?.name ?? "Agent")'?",
+ isPresented: $showDeleteConfirmation
+ ) {
+ Button("Cancel", role: .cancel) { }
+ Button("Delete", role: .destructive) { performDelete() }
+ }
+ }
+
+ private func loadSelectedAgentSubMode() {
+ let subMode = AppState.shared.getSelectedAgentSubMode()
+
+ // Try to find the agent
+ if let agent = findAgent(byId: subMode) {
+ // If it's not the default agent and custom agents are disabled, reset to default
+ if !agent.isDefaultAgent && !isCustomAgentPolicyEnabled {
+ selectedAgent = builtInAgents.first(where: { $0.isDefaultAgent }) ?? .defaultAgent
+ AppState.shared.setSelectedAgentSubMode("Agent")
+ return
+ }
+ selectedAgent = agent
+ return
+ }
+
+ // Default to Agent mode if nothing matches
+ selectedAgent = builtInAgents.first(where: { $0.isDefaultAgent }) ?? .defaultAgent
+ }
+
+ private func findAgent(byId id: String) -> ConversationMode? {
+ // Check built-in agents first
+ if let builtIn = builtInAgents.first(where: { $0.id == id }) {
+ return builtIn
+ }
+ // Check custom agents
+ if let custom = customAgents.first(where: { $0.id == id }) {
+ return custom
+ }
+ return nil
+ }
+}
diff --git a/Core/Sources/ConversationTab/ModelPicker/ModeButton.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/ModeButton.swift
similarity index 89%
rename from Core/Sources/ConversationTab/ModelPicker/ModeButton.swift
rename to Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/ModeButton.swift
index 53106ba2..7964d448 100644
--- a/Core/Sources/ConversationTab/ModelPicker/ModeButton.swift
+++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/ModeButton.swift
@@ -12,13 +12,13 @@ public struct ModeButton: View {
public var body: some View {
Button(action: action) {
Text(title)
- .scaledFont(.body)
+ .scaledFont(size: 12)
.scaledPadding(.horizontal, 6)
- .scaledPadding(.vertical, 0)
+ .scaledPadding(.vertical, 2)
.frame(maxHeight: .infinity, alignment: .center)
.background(isSelected ? activeBackground : Color.clear)
.foregroundColor(isSelected ? activeTextColor : inactiveTextColor)
- .cornerRadius(5)
+ .cornerRadius(16)
.shadow(color: .black.opacity(0.05), radius: 0.375, x: 0, y: 1)
.shadow(color: .black.opacity(0.15), radius: 0.125, x: 0, y: 0.25)
.overlay(
diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModelManagerUtils.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelManagerUtils.swift
new file mode 100644
index 00000000..dda65e47
--- /dev/null
+++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelManagerUtils.swift
@@ -0,0 +1,380 @@
+import Foundation
+import Combine
+import Persist
+import GitHubCopilotService
+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 {
+ func isSelectedModelSupportVision() -> Bool? {
+ if let savedModel = get(key: SELECTED_LLM_KEY) {
+ return savedModel["supportVision"]?.boolValue
+ }
+ return nil
+ }
+
+ func getSelectedModel() -> LLMModel? {
+ guard let savedModel = get(key: SELECTED_LLM_KEY) else {
+ return nil
+ }
+
+ guard let modelName = savedModel["modelName"]?.stringValue,
+ let modelFamily = savedModel["modelFamily"]?.stringValue,
+ let id = savedModel["id"]?.stringValue else {
+ return nil
+ }
+
+ let displayName = savedModel["displayName"]?.stringValue
+ let providerName = savedModel["providerName"]?.stringValue
+ let supportVision = savedModel["supportVision"]?.boolValue ?? false
+ let degradationReason = savedModel["degradationReason"]?.stringValue
+ 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,
+ let multiplier = savedModel["billing"]?["multiplier"]?.numberValue {
+ billing = CopilotModelBilling(
+ isPremium: isPremium,
+ multiplier: Float(multiplier)
+ )
+ }
+
+ return LLMModel(
+ displayName: displayName,
+ modelName: modelName,
+ modelFamily: modelFamily,
+ id: id,
+ billing: billing,
+ providerName: providerName,
+ supportVision: supportVision,
+ degradationReason: degradationReason,
+ reasoningEfforts: reasoningEfforts,
+ supportsReasoningEffortLevel: supportsReasoningEffortLevel
+ )
+ }
+
+ func setSelectedModel(_ model: LLMModel) {
+ update(key: SELECTED_LLM_KEY, value: model)
+ DispatchQueue.main.async {
+ NotificationCenter.default.post(name: .gitHubCopilotSelectedModelDidChange, object: nil)
+ }
+ }
+
+ 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
+ }
+
+ func getSelectedChatMode() -> String {
+ if let savedMode = get(key: SELECTED_CHATMODE_KEY),
+ let modeName = savedMode.stringValue {
+ return convertChatMode(modeName)
+ }
+
+ // Default to "Agent"
+ return "Agent"
+ }
+
+ func setSelectedChatMode(_ mode: String) {
+ update(key: SELECTED_CHATMODE_KEY, value: mode)
+ }
+
+ func isAgentModeEnabled() -> Bool {
+ return getSelectedChatMode() == "Agent"
+ }
+
+ func getSelectedAgentSubMode() -> String {
+ if let savedSubMode = get(key: SELECTED_AGENT_SUBMODE_KEY),
+ let subMode = savedSubMode.stringValue {
+ return subMode
+ }
+ // Default to "Agent"
+ return "Agent"
+ }
+
+ func setSelectedAgentSubMode(_ subMode: String) {
+ update(key: SELECTED_AGENT_SUBMODE_KEY, value: subMode)
+ }
+
+ private func convertChatMode(_ mode: String) -> String {
+ switch mode {
+ case "Ask":
+ return "Ask"
+ default:
+ return "Agent"
+ }
+ }
+}
+
+public class CopilotModelManagerObservable: ObservableObject {
+ static let shared = CopilotModelManagerObservable()
+
+ @Published var availableChatModels: [LLMModel] = []
+ @Published var availableAgentModels: [LLMModel] = []
+ @Published var defaultChatModel: LLMModel?
+ @Published var defaultAgentModel: LLMModel?
+ @Published var availableChatBYOKModels: [LLMModel] = []
+ @Published var availableAgentBYOKModels: [LLMModel] = []
+ private var cancellables = Set()
+
+ private init() {
+ // Initial load
+ availableChatModels = CopilotModelManager.getAvailableChatLLMs(scope: .chatPanel)
+ availableAgentModels = CopilotModelManager.getAvailableChatLLMs(scope: .agentPanel)
+ defaultChatModel = CopilotModelManager.getDefaultChatModel(scope: .chatPanel)
+ defaultAgentModel = CopilotModelManager.getDefaultChatModel(scope: .agentPanel)
+ availableChatBYOKModels = BYOKModelManager.getAvailableChatLLMs(scope: .chatPanel)
+ availableAgentBYOKModels = BYOKModelManager.getAvailableChatLLMs(scope: .agentPanel)
+
+ // Setup notification to update when models change
+ NotificationCenter.default.publisher(for: .gitHubCopilotModelsDidChange)
+ .receive(on: DispatchQueue.main)
+ .sink { [weak self] _ in
+ self?.availableChatModels = CopilotModelManager.getAvailableChatLLMs(scope: .chatPanel)
+ self?.availableAgentModels = CopilotModelManager.getAvailableChatLLMs(scope: .agentPanel)
+ self?.defaultChatModel = CopilotModelManager.getDefaultChatModel(scope: .chatPanel)
+ self?.defaultAgentModel = CopilotModelManager.getDefaultChatModel(scope: .agentPanel)
+ self?.availableChatBYOKModels = BYOKModelManager.getAvailableChatLLMs(scope: .chatPanel)
+ self?.availableAgentBYOKModels = BYOKModelManager.getAvailableChatLLMs(scope: .agentPanel)
+ }
+ .store(in: &cancellables)
+
+ NotificationCenter.default.publisher(for: .gitHubCopilotShouldSwitchFallbackModel)
+ .receive(on: DispatchQueue.main)
+ .sink { _ in
+ if let fallbackModel = CopilotModelManager.getFallbackLLM(
+ scope: AppState.shared
+ .isAgentModeEnabled() ? .agentPanel : .chatPanel
+ ) {
+ AppState.shared.setSelectedModel(fallbackModel.toLLMModel())
+ }
+ }
+ .store(in: &cancellables)
+ }
+}
+
+// MARK: - Copilot Model Manager
+public extension CopilotModelManager {
+ static func getAvailableChatLLMs(scope: PromptTemplateScope = .chatPanel) -> [LLMModel] {
+ let LLMs = CopilotModelManager.getAvailableLLMs()
+ return LLMs.filter(
+ { $0.scopes.contains(scope) }
+ ).map {
+ $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 })
+ ?? LLMsInScope.first(where: { $0.isChatDefault })
+ // If a default model is found, return it
+ if let defaultModel = defaultModel {
+ return defaultModel.toLLMModel()
+ }
+
+ // Fallback to gpt-4.1 if available
+ 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 {
+ return firstModel.toLLMModel()
+ }
+
+ return nil
+ }
+}
+
+// MARK: - BYOK Model Manager
+public extension BYOKModelManager {
+ static func getAvailableChatLLMs(scope: PromptTemplateScope = .chatPanel) -> [LLMModel] {
+ var BYOKModels = BYOKModelManager.getRegisteredBYOKModels()
+ if scope == .agentPanel {
+ BYOKModels = BYOKModels.filter(
+ { $0.modelCapabilities?.toolCalling == true }
+ )
+ }
+ return BYOKModels.map {
+ return LLMModel(
+ displayName: $0.modelCapabilities?.name,
+ modelName: $0.modelId,
+ modelFamily: $0.modelId,
+ id: $0.modelId,
+ billing: nil,
+ providerName: $0.providerName.rawValue,
+ supportVision: $0.modelCapabilities?.vision ?? false,
+ maxInputTokens: $0.modelCapabilities?.maxInputTokens,
+ maxOutputTokens: $0.modelCapabilities?.maxOutputTokens
+ )
+ }
+ }
+}
+
+public struct LLMModel: Codable, Hashable, Equatable {
+ public let displayName: String?
+ 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,
+ vendor: String? = nil,
+ billing: CopilotModelBilling? = nil,
+ providerName: String? = nil,
+ 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)
+ }
+}
+
+public extension LLMModel {
+ /// Apply to `Copilot Models`
+ var isPremiumModel: Bool { billing?.isPremium == true }
+ /// Apply to `Copilot Models`
+ var isStandardModel: Bool { !isPremiumModel || billing == nil }
+ /// Apply to `Copilot Models`
+ var isAutoModel: Bool { isStandardModel && modelName == "Auto" }
+
+ 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
+ )
+ }
+}
diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModelMenuItemFormatter.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelMenuItemFormatter.swift
new file mode 100644
index 00000000..9b6abf0e
--- /dev/null
+++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelMenuItemFormatter.swift
@@ -0,0 +1,170 @@
+import AppKit
+import Foundation
+
+public struct ScopeCache {
+ var modelMultiplierCache: [String: String] = [:]
+ var cachedMaxWidth: CGFloat = 0
+ var lastModelsHash: Int = 0
+}
+
+// MARK: - Model Menu Item Formatting
+public struct ModelMenuItemFormatter {
+ public static let minimumPadding: Int = 24
+
+ public static let attributes: [NSAttributedString.Key: NSFont] = [.font: NSFont.systemFont(ofSize: NSFont.systemFontSize)]
+
+ public static var spaceWidth: CGFloat {
+ "\u{200A}".size(withAttributes: attributes).width
+ }
+
+ public static var minimumPaddingWidth: CGFloat {
+ spaceWidth * CGFloat(minimumPadding)
+ }
+
+ /// Creates an attributed string for model menu items with proper spacing and formatting
+ public static func createModelMenuItemAttributedString(
+ modelName: String,
+ isSelected: Bool,
+ multiplierText: String,
+ targetWidth: CGFloat? = nil,
+ isDegraded: Bool = false
+ ) -> AttributedString {
+ let prefix: String
+ if isDegraded {
+ prefix = "β "
+ } else if isSelected {
+ prefix = "β "
+ } else {
+ prefix = " "
+ }
+ let displayName = "\(prefix)\(modelName)"
+
+ var fullString = displayName
+ var attributedString = AttributedString(fullString)
+
+ if !multiplierText.isEmpty {
+ let displayNameWidth = displayName.size(withAttributes: attributes).width
+ let multiplierTextWidth = multiplierText.size(withAttributes: attributes).width
+
+ // Calculate padding needed
+ let neededPaddingWidth: CGFloat
+
+ if let targetWidth = targetWidth {
+ neededPaddingWidth = targetWidth - displayNameWidth - multiplierTextWidth
+ } else {
+ neededPaddingWidth = minimumPaddingWidth
+ }
+
+ let finalPaddingWidth = max(neededPaddingWidth, minimumPaddingWidth)
+ let numberOfSpaces = Int(round(finalPaddingWidth / spaceWidth))
+ let padding = String(repeating: "\u{200A}", count: max(minimumPadding, numberOfSpaces))
+ fullString = "\(displayName)\(padding)\(multiplierText)"
+
+ attributedString = AttributedString(fullString)
+
+ if let range = attributedString.range(
+ of: multiplierText,
+ options: .backwards
+ ) {
+ attributedString[range].foregroundColor = .secondary
+ }
+ }
+
+ return attributedString
+ }
+
+ /// Gets the 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"
+ }
+ if model.billing?.tokenBasedBillingEnabled == true {
+ var parts: [String] = []
+ if let tokens = model.maxContextWindowTokens {
+ parts.append(formatContextWindow(tokens))
+ }
+ 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 {
+ 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/ModelPicker/ChatModePicker.swift b/Core/Sources/ConversationTab/ModelPicker/ChatModePicker.swift
deleted file mode 100644
index 44d8c8cc..00000000
--- a/Core/Sources/ConversationTab/ModelPicker/ChatModePicker.swift
+++ /dev/null
@@ -1,96 +0,0 @@
-import SwiftUI
-import Persist
-import ConversationServiceProvider
-import GitHubCopilotService
-import Combine
-import SharedUIComponents
-
-public extension Notification.Name {
- static let gitHubCopilotChatModeDidChange = Notification
- .Name("com.github.CopilotForXcode.ChatModeDidChange")
-}
-
-public enum ChatMode: String {
- case Ask = "Ask"
- case Agent = "Agent"
-}
-
-public struct ChatModePicker: View {
- @Binding var chatMode: String
- @Environment(\.colorScheme) var colorScheme
- @State var isAgentModeFFEnabled: Bool
- @State private var cancellables = Set()
- var onScopeChange: (PromptTemplateScope) -> Void
-
- public init(chatMode: Binding, onScopeChange: @escaping (PromptTemplateScope) -> Void = { _ in }) {
- self._chatMode = chatMode
- self.onScopeChange = onScopeChange
- self.isAgentModeFFEnabled = FeatureFlagNotifierImpl.shared.featureFlags.agentMode
- }
-
- private func setChatMode(mode: ChatMode) {
- chatMode = mode.rawValue
- AppState.shared.setSelectedChatMode(mode.rawValue)
- onScopeChange(mode == .Ask ? .chatPanel : .agentPanel)
- NotificationCenter.default.post(
- name: .gitHubCopilotChatModeDidChange,
- object: nil
- )
- }
-
- private func subscribeToFeatureFlagsDidChangeEvent() {
- FeatureFlagNotifierImpl.shared.featureFlagsDidChange.sink(receiveValue: { featureFlags in
- isAgentModeFFEnabled = featureFlags.agentMode
- })
- .store(in: &cancellables)
- }
-
- public var body: some View {
- VStack {
- if isAgentModeFFEnabled {
- HStack(spacing: -1) {
- ModeButton(
- title: "Ask",
- isSelected: chatMode == "Ask",
- activeBackground: colorScheme == .dark ? Color.white.opacity(0.25) : Color.white,
- activeTextColor: Color.primary,
- inactiveTextColor: Color.primary.opacity(0.5),
- action: {
- setChatMode(mode: .Ask)
- }
- )
-
- ModeButton(
- title: "Agent",
- isSelected: chatMode == "Agent",
- activeBackground: Color.blue,
- activeTextColor: Color.white,
- inactiveTextColor: Color.primary.opacity(0.5),
- action: {
- setChatMode(mode: .Agent)
- }
- )
- }
- .scaledPadding(1)
- .scaledFrame(height: 20, alignment: .topLeading)
- .background(.primary.opacity(0.1))
- .cornerRadius(5)
- .padding(4)
- .help("Set Mode")
- } else {
- EmptyView()
- }
- }
- .task {
- subscribeToFeatureFlagsDidChangeEvent()
- if !isAgentModeFFEnabled {
- setChatMode(mode: .Ask)
- }
- }
- .onChange(of: isAgentModeFFEnabled) { newAgentModeFFEnabled in
- if !newAgentModeFFEnabled {
- setChatMode(mode: .Ask)
- }
- }
- }
-}
diff --git a/Core/Sources/ConversationTab/ModelPicker/ModelManagerUtils.swift b/Core/Sources/ConversationTab/ModelPicker/ModelManagerUtils.swift
deleted file mode 100644
index 92af4af9..00000000
--- a/Core/Sources/ConversationTab/ModelPicker/ModelManagerUtils.swift
+++ /dev/null
@@ -1,253 +0,0 @@
-import Foundation
-import Combine
-import Persist
-import GitHubCopilotService
-import ConversationServiceProvider
-
-public let SELECTED_LLM_KEY = "selectedLLM"
-public let SELECTED_CHATMODE_KEY = "selectedChatMode"
-
-public extension Notification.Name {
- static let gitHubCopilotSelectedModelDidChange = Notification.Name("com.github.CopilotForXcode.SelectedModelDidChange")
-}
-
-public extension AppState {
- func isSelectedModelSupportVision() -> Bool? {
- if let savedModel = get(key: SELECTED_LLM_KEY) {
- return savedModel["supportVision"]?.boolValue
- }
- return nil
- }
-
- func getSelectedModel() -> LLMModel? {
- guard let savedModel = get(key: SELECTED_LLM_KEY) else {
- return nil
- }
-
- guard let modelName = savedModel["modelName"]?.stringValue,
- let modelFamily = savedModel["modelFamily"]?.stringValue else {
- return nil
- }
-
- let displayName = savedModel["displayName"]?.stringValue
- let providerName = savedModel["providerName"]?.stringValue
- let supportVision = savedModel["supportVision"]?.boolValue ?? false
-
- // Try to reconstruct billing info if available
- var billing: CopilotModelBilling?
- if let isPremium = savedModel["billing"]?["isPremium"]?.boolValue,
- let multiplier = savedModel["billing"]?["multiplier"]?.numberValue {
- billing = CopilotModelBilling(
- isPremium: isPremium,
- multiplier: Float(multiplier)
- )
- }
-
- return LLMModel(
- displayName: displayName,
- modelName: modelName,
- modelFamily: modelFamily,
- billing: billing,
- providerName: providerName,
- supportVision: supportVision
- )
- }
-
- func setSelectedModel(_ model: LLMModel) {
- update(key: SELECTED_LLM_KEY, value: model)
- NotificationCenter.default.post(name: .gitHubCopilotSelectedModelDidChange, object: nil)
- }
-
- func modelScope() -> PromptTemplateScope {
- return isAgentModeEnabled() ? .agentPanel : .chatPanel
- }
-
- func getSelectedChatMode() -> String {
- if let savedMode = get(key: SELECTED_CHATMODE_KEY),
- let modeName = savedMode.stringValue {
- return convertChatMode(modeName)
- }
-
- // Default to "Agent"
- return "Agent"
- }
-
- func setSelectedChatMode(_ mode: String) {
- update(key: SELECTED_CHATMODE_KEY, value: mode)
- }
-
- func isAgentModeEnabled() -> Bool {
- return getSelectedChatMode() == "Agent"
- }
-
- private func convertChatMode(_ mode: String) -> String {
- switch mode {
- case "Ask":
- return "Ask"
- default:
- return "Agent"
- }
- }
-}
-
-public class CopilotModelManagerObservable: ObservableObject {
- static let shared = CopilotModelManagerObservable()
-
- @Published var availableChatModels: [LLMModel] = []
- @Published var availableAgentModels: [LLMModel] = []
- @Published var defaultChatModel: LLMModel?
- @Published var defaultAgentModel: LLMModel?
- @Published var availableChatBYOKModels: [LLMModel] = []
- @Published var availableAgentBYOKModels: [LLMModel] = []
- private var cancellables = Set()
-
- private init() {
- // Initial load
- availableChatModels = CopilotModelManager.getAvailableChatLLMs(scope: .chatPanel)
- availableAgentModels = CopilotModelManager.getAvailableChatLLMs(scope: .agentPanel)
- defaultChatModel = CopilotModelManager.getDefaultChatModel(scope: .chatPanel)
- defaultAgentModel = CopilotModelManager.getDefaultChatModel(scope: .agentPanel)
- availableChatBYOKModels = BYOKModelManager.getAvailableChatLLMs(scope: .chatPanel)
- availableAgentBYOKModels = BYOKModelManager.getAvailableChatLLMs(scope: .agentPanel)
-
- // Setup notification to update when models change
- NotificationCenter.default.publisher(for: .gitHubCopilotModelsDidChange)
- .receive(on: DispatchQueue.main)
- .sink { [weak self] _ in
- self?.availableChatModels = CopilotModelManager.getAvailableChatLLMs(scope: .chatPanel)
- self?.availableAgentModels = CopilotModelManager.getAvailableChatLLMs(scope: .agentPanel)
- self?.defaultChatModel = CopilotModelManager.getDefaultChatModel(scope: .chatPanel)
- self?.defaultAgentModel = CopilotModelManager.getDefaultChatModel(scope: .agentPanel)
- self?.availableChatBYOKModels = BYOKModelManager.getAvailableChatLLMs(scope: .chatPanel)
- self?.availableAgentBYOKModels = BYOKModelManager.getAvailableChatLLMs(scope: .agentPanel)
- }
- .store(in: &cancellables)
-
- NotificationCenter.default.publisher(for: .gitHubCopilotShouldSwitchFallbackModel)
- .receive(on: DispatchQueue.main)
- .sink { _ in
- if let fallbackModel = CopilotModelManager.getFallbackLLM(
- scope: AppState.shared
- .isAgentModeEnabled() ? .agentPanel : .chatPanel
- ) {
- AppState.shared.setSelectedModel(
- .init(
- modelName: fallbackModel.modelName,
- modelFamily: fallbackModel.id,
- billing: fallbackModel.billing,
- supportVision: fallbackModel.capabilities.supports.vision
- )
- )
- }
- }
- .store(in: &cancellables)
- }
-}
-
-// MARK: - Copilot Model Manager
-public extension CopilotModelManager {
- static func getAvailableChatLLMs(scope: PromptTemplateScope = .chatPanel) -> [LLMModel] {
- let LLMs = CopilotModelManager.getAvailableLLMs()
- return LLMs.filter(
- { $0.scopes.contains(scope) }
- ).map {
- return LLMModel(
- modelName: $0.modelName,
- modelFamily: $0.isChatFallback ? $0.id : $0.modelFamily,
- billing: $0.billing,
- supportVision: $0.capabilities.supports.vision
- )
- }
- }
-
- 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 })
- // If a default model is found, return it
- if let defaultModel = defaultModel {
- return LLMModel(
- modelName: defaultModel.modelName,
- modelFamily: defaultModel.modelFamily,
- billing: defaultModel.billing,
- supportVision: defaultModel.capabilities.supports.vision
- )
- }
-
- // 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,
- billing: gpt4_1.billing,
- supportVision: gpt4_1.capabilities.supports.vision
- )
- }
-
- // If no default model is found, fallback to the first available model
- if let firstModel = LLMsInScope.first {
- return LLMModel(
- modelName: firstModel.modelName,
- modelFamily: firstModel.modelFamily,
- billing: firstModel.billing,
- supportVision: firstModel.capabilities.supports.vision
- )
- }
-
- return nil
- }
-}
-
-// MARK: - BYOK Model Manager
-public extension BYOKModelManager {
- static func getAvailableChatLLMs(scope: PromptTemplateScope = .chatPanel) -> [LLMModel] {
- var BYOKModels = BYOKModelManager.getRegisteredBYOKModels()
- if scope == .agentPanel {
- BYOKModels = BYOKModels.filter(
- { $0.modelCapabilities?.toolCalling == true }
- )
- }
- return BYOKModels.map {
- return LLMModel(
- displayName: $0.modelCapabilities?.name,
- modelName: $0.modelId,
- modelFamily: $0.modelId,
- billing: nil,
- providerName: $0.providerName.rawValue,
- supportVision: $0.modelCapabilities?.vision ?? false
- )
- }
- }
-}
-
-public struct LLMModel: Codable, Hashable, Equatable {
- let displayName: String?
- let modelName: String
- let modelFamily: String
- let billing: CopilotModelBilling?
- let providerName: String?
- let supportVision: Bool
-
- public init(
- displayName: String? = nil,
- modelName: String,
- modelFamily: String,
- billing: CopilotModelBilling?,
- providerName: String? = nil,
- supportVision: Bool
- ) {
- self.displayName = displayName
- self.modelName = modelName
- self.modelFamily = modelFamily
- self.billing = billing
- self.providerName = providerName
- self.supportVision = supportVision
- }
-}
-
-public struct ScopeCache {
- var modelMultiplierCache: [String: String] = [:]
- var cachedMaxWidth: CGFloat = 0
- var lastModelsHash: Int = 0
-}
diff --git a/Core/Sources/ConversationTab/TerminalViews/RunInTerminalToolView.swift b/Core/Sources/ConversationTab/TerminalViews/RunInTerminalToolView.swift
index e2182eda..7f6725a5 100644
--- a/Core/Sources/ConversationTab/TerminalViews/RunInTerminalToolView.swift
+++ b/Core/Sources/ConversationTab/TerminalViews/RunInTerminalToolView.swift
@@ -1,9 +1,11 @@
-import SwiftUI
-import XcodeInspector
-import ConversationServiceProvider
+import ChatService
import ComposableArchitecture
-import Terminal
+import ConversationServiceProvider
+import GitHubCopilotService
import SharedUIComponents
+import SwiftUI
+import Terminal
+import XcodeInspector
struct RunInTerminalToolView: View {
let tool: AgentToolCall
@@ -23,7 +25,10 @@ struct RunInTerminalToolView: View {
init(tool: AgentToolCall, chat: StoreOf) {
self.tool = tool
self.chat = chat
- if let input = tool.invokeParams?.input as? [String: AnyCodable] {
+
+ let input = (tool.invokeParams?.input as? [String: AnyCodable]) ?? tool.input
+
+ if let input {
self.command = input["command"]?.value as? String
self.explanation = input["explanation"]?.value as? String
self.isBackground = input["isBackground"]?.value as? Bool
@@ -121,10 +126,12 @@ struct RunInTerminalToolView: View {
.scaledFrame(width: 16, height: 16)
Text(command!)
+ .lineLimit(nil)
.textSelection(.enabled)
.scaledFont(size: chatFontSize, design: .monospaced)
.scaledPadding(8)
.frame(maxWidth: .infinity, alignment: .leading)
+ .fixedSize(horizontal: false, vertical: true)
.foregroundStyle(codeForegroundColor)
.background(codeBackgroundColor)
.clipShape(RoundedRectangle(cornerRadius: 6))
@@ -141,7 +148,7 @@ struct RunInTerminalToolView: View {
terminalSession: terminalSession,
onTerminalInput: terminalSession.handleTerminalInput
)
- .frame(minHeight: 200, maxHeight: 400)
+ .scaledFrame(minHeight: 200, maxHeight: 400)
} else if tool.status == .waitForConfirmation {
ThemedMarkdownText(text: explanation ?? "", chat: chat)
.frame(maxWidth: .infinity, alignment: .leading)
@@ -153,14 +160,28 @@ struct RunInTerminalToolView: View {
Text("Skip")
.scaledFont(.body)
}
-
- Button(action: {
- chat.send(.toolCallAccepted(tool.id))
- }) {
- Text("Allow")
- .scaledFont(.body)
+
+ if FeatureFlagNotifierImpl.shared.featureFlags.agentModeAutoApproval &&
+ CopilotPolicyNotifierImpl.shared.copilotPolicy.agentModeAutoApprovalEnabled,
+ let command, !command.isEmpty {
+ SplitButton(
+ title: "Allow",
+ isDisabled: false,
+ primaryAction: {
+ chat.send(.toolCallAccepted(tool.id))
+ },
+ menuItems: terminalMenuItems(command: command),
+ style: .prominent
+ )
+ } else {
+ Button(action: {
+ chat.send(.toolCallAccepted(tool.id))
+ }) {
+ Text("Allow")
+ .scaledFont(.body)
+ }
+ .buttonStyle(BorderedProminentButtonStyle())
}
- .buttonStyle(BorderedProminentButtonStyle())
}
.frame(maxWidth: .infinity, alignment: .leading)
.scaledPadding(.top, 4)
@@ -168,4 +189,171 @@ struct RunInTerminalToolView: View {
}
}
}
+
+ private func terminalMenuItems(command: String) -> [SplitButtonMenuItem] {
+ var items: [SplitButtonMenuItem] = []
+
+ let subCommands = ToolAutoApprovalManager.extractSubCommandsWithTreeSitter(command)
+ let commandNames = extractCommandNamesForMenu(subCommands)
+ let commandNamesLabel = formatCommandNameListForMenu(commandNames)
+
+ let trimmedCommand = command.trimmingCharacters(in: .whitespacesAndNewlines)
+ let trimmedSubCommands = subCommands
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+ .filter { !$0.isEmpty }
+
+ let shouldShowExactCommandLineItems = !(
+ trimmedSubCommands.count == 1 &&
+ trimmedSubCommands[0] == trimmedCommand &&
+ commandNames.contains(trimmedCommand)
+ )
+
+ let conversationId = tool.invokeParams?.conversationId ?? ""
+ let hasConversationId = !conversationId.isEmpty
+
+ // Session-scoped
+ if hasConversationId, !commandNames.isEmpty {
+ items.append(
+ SplitButtonMenuItem(title: sessionAllowCommandsTitle(commandNamesLabel: commandNamesLabel, commandCount: commandNames.count)) {
+ chat.send(
+ .toolCallAcceptedWithApproval(
+ tool.id,
+ .terminal(
+ scope: .session(conversationId),
+ commands: commandNames
+ )
+ )
+ )
+ }
+ )
+ }
+
+ // Global
+ if !commandNames.isEmpty {
+ items.append(
+ SplitButtonMenuItem(title: alwaysAllowCommandsTitle(commandNamesLabel: commandNamesLabel, commandCount: commandNames.count)) {
+ chat.send(
+ .toolCallAcceptedWithApproval(
+ tool.id,
+ .terminal(
+ scope: .global,
+ commands: commandNames
+ )
+ )
+ )
+ }
+ )
+ }
+
+ items.append(.divider())
+
+ if shouldShowExactCommandLineItems {
+ // Session-scoped exact command line
+ if hasConversationId {
+ items.append(
+ SplitButtonMenuItem(title: "Allow Exact Command Line in this Session") {
+ chat.send(
+ .toolCallAcceptedWithApproval(
+ tool.id,
+ .terminal(
+ scope: .session(conversationId),
+ commands: [command]
+ )
+ )
+ )
+ }
+ )
+ }
+
+ // Global exact command line
+ items.append(
+ SplitButtonMenuItem(title: "Always Allow Exact Command Line") {
+ chat.send(
+ .toolCallAcceptedWithApproval(
+ tool.id,
+ .terminal(
+ scope: .global,
+ commands: [command]
+ )
+ )
+ )
+ }
+ )
+
+ items.append(.divider())
+ }
+
+ // Session-scoped allow all
+ if hasConversationId {
+ items.append(
+ SplitButtonMenuItem(title: "Allow All Commands in this Session") {
+ chat.send(
+ .toolCallAcceptedWithApproval(
+ tool.id,
+ .terminal(
+ scope: .session(conversationId),
+ commands: []
+ )
+ )
+ )
+ }
+ )
+ }
+
+ items.append(.divider())
+ items.append(
+ SplitButtonMenuItem(title: "Configure Auto Approve...") {
+ chat.send(.openAutoApproveSettings)
+ }
+ )
+
+ return items
+ }
+
+ private func formatSubCommandListForMenu(_ subCommands: [String]) -> String {
+ let trimmed = subCommands.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty }
+ guard !trimmed.isEmpty else { return "(none)" }
+ return trimmed.joined(separator: ", ")
+ }
+
+ private func extractCommandNamesForMenu(_ subCommands: [String]) -> [String] {
+ var result: [String] = []
+ var seen: Set = []
+
+ for subCommand in subCommands {
+ guard let name = ToolAutoApprovalManager.extractTerminalCommandName(fromSubCommand: subCommand) else {
+ continue
+ }
+ let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { continue }
+ guard !seen.contains(trimmed) else { continue }
+ seen.insert(trimmed)
+ result.append(trimmed)
+ }
+
+ return result
+ }
+
+ private func formatCommandNameListForMenu(_ commandNames: [String]) -> String {
+ let trimmed = commandNames.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty }
+ guard !trimmed.isEmpty else { return "(none)" }
+
+ func suffixEllipsis(_ name: String) -> String { "`\(name) ...`" }
+
+ return trimmed.map(suffixEllipsis).joined(separator: ", ")
+ }
+
+ private func sessionAllowCommandsTitle(commandNamesLabel: String, commandCount: Int) -> String {
+ if commandCount == 1 {
+ return "Allow \(commandNamesLabel) in this Session"
+ }
+ return "Allow Commands \(commandNamesLabel) in this Session"
+ }
+
+ private func alwaysAllowCommandsTitle(commandNamesLabel: String, commandCount: Int) -> String {
+ if commandCount == 1 {
+ return "Always Allow \(commandNamesLabel)"
+ }
+ return "Always Allow Commands \(commandNamesLabel)"
+ }
}
diff --git a/Core/Sources/ConversationTab/Views/BotMessage.swift b/Core/Sources/ConversationTab/Views/BotMessage.swift
index d951f589..8d75db57 100644
--- a/Core/Sources/ConversationTab/Views/BotMessage.swift
+++ b/Core/Sources/ConversationTab/Views/BotMessage.swift
@@ -7,6 +7,7 @@ import SwiftUI
import ConversationServiceProvider
import ChatTab
import ChatAPIService
+import HostAppActivator
struct BotMessage: View {
var r: Double { messageBubbleCornerRadius }
@@ -20,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 }
@@ -27,43 +29,12 @@ struct BotMessage: View {
@Environment(\.colorScheme) var colorScheme
@AppStorage(\.chatFontSize) var chatFontSize
- @State var isReferencesPresented = false
@State var isHovering = false
- struct ResponseToolBar: View {
- let id: String
- let chat: StoreOf
- let text: String
-
- var body: some View {
- HStack(spacing: 4) {
-
- UpvoteButton { rating in
- chat.send(.upvote(id, rating))
- }
-
- DownvoteButton { rating in
- chat.send(.downvote(id, rating))
- }
-
- CopyButton {
- NSPasteboard.general.clearContents()
- NSPasteboard.general.setString(text, forType: .string)
- chat.send(.copyCode(id))
- }
- }
- }
- }
-
struct ReferenceButton: View {
- var r: Double { messageBubbleCornerRadius }
let references: [ConversationReference]
let chat: StoreOf
- @Binding var isReferencesPresented: Bool
-
- @State var isReferencesHovered = false
-
@AppStorage(\.chatFontSize) var chatFontSize
func MakeReferenceTitle(references: [ConversationReference]) -> String {
@@ -76,63 +47,28 @@ struct BotMessage: View {
return title
}
- var referenceIcon: some View {
- Group {
- if !isReferencesPresented {
- HStack(alignment: .center, spacing: 0) {
- Image(systemName: "chevron.right")
- }
- .scaledPadding(.leading, 4)
- .scaledPadding(.trailing, 3)
- .scaledPadding(.vertical, 1.5)
- } else {
- HStack(alignment: .center, spacing: 0) {
- Image(systemName: "chevron.down")
- }
- .scaledPadding(.top, 4)
- .scaledPadding(.bottom, 3)
- .scaledPadding(.horizontal, 1.5)
-
- }
- }
- .scaledFont(size: chatFontSize - 1, weight: .medium)
- .scaledFrame(width: 16, height: 16, alignment: .center)
- }
-
var body: some View {
+ let files = references.map { $0.filePath }
+ let fileHelpTexts = Dictionary(uniqueKeysWithValues: references.compactMap { reference in
+ guard reference.url != nil else { return nil }
+ return (reference.filePath, reference.getPathRelativeToHome())
+ })
+ let progressMessage = Text(MakeReferenceTitle(references: references))
+ .foregroundStyle(.secondary)
+
HStack(spacing: 0) {
- VStack(alignment: .leading, spacing: 8) {
- Button(action: {
- isReferencesPresented.toggle()
- }, label: {
- HStack(spacing: 4) {
- referenceIcon
-
- Text(MakeReferenceTitle(references: references))
- .scaledFont(size: chatFontSize - 1)
+ ExpandableFileListView(
+ progressMessage: progressMessage,
+ files: files,
+ chatFontSize: chatFontSize,
+ helpText: "View referenced files",
+ onFileClick: { filePath in
+ if let reference = references.first(where: { $0.filePath == filePath }) {
+ chat.send(.referenceClicked(reference))
}
- .foregroundStyle(.secondary)
- })
- .buttonStyle(.plain)
- .padding(.vertical, 4)
- .padding(.trailing, 4)
- .background {
- RoundedRectangle(cornerRadius: r - 4)
- .fill(isReferencesHovered ? Color.gray.opacity(0.2) : Color.clear)
- }
- .accessibilityValue(isReferencesPresented ? "Collapse" : "Expand")
-
- if isReferencesPresented {
- ReferenceList(references: references, chat: chat)
- .background(
- RoundedRectangle(cornerRadius: 5)
- .stroke(Color.gray, lineWidth: 0.2)
- )
- }
- }
- .onHover {
- isReferencesHovered = $0
- }
+ },
+ fileHelpTexts: fileHelpTexts
+ )
Spacer()
}
@@ -140,103 +76,151 @@ struct BotMessage: View {
}
var body: some View {
- HStack {
- VStack(alignment: .leading, spacing: 8) {
- if !references.isEmpty {
- WithPerceptionTracking {
- ReferenceButton(
- references: references,
- chat: chat,
- isReferencesPresented: $isReferencesPresented
+ WithPerceptionTracking {
+ HStack {
+ VStack(alignment: .leading, spacing: 8) {
+ if !references.isEmpty {
+ WithPerceptionTracking {
+ ReferenceButton(
+ references: references,
+ chat: chat
+ )
+ }
+ }
+
+ // progress step
+ if steps.count > 0 {
+ ProgressStep(steps: steps)
+
+ }
+
+ ForEach(Array(thinking.enumerated()), id: \.offset) { index, entry in
+ ThinkingView(
+ thinking: entry,
+ isStreaming: index == thinking.count - 1 && isThinkingStreaming()
)
}
- }
-
- // progress step
- if steps.count > 0 {
- ProgressStep(steps: steps)
-
- }
-
- if !panelMessages.isEmpty {
- WithPerceptionTracking {
- ForEach(panelMessages.indices, id: \.self) { index in
- FunctionMessage(text: panelMessages[index].message, chat: chat)
+
+ if !panelMessages.isEmpty {
+ WithPerceptionTracking {
+ ForEach(panelMessages.indices, id: \.self) { index in
+ FunctionMessage(text: panelMessages[index].message, chat: chat)
+ }
}
}
- }
-
- if editAgentRounds.count > 0 {
- ProgressAgentRound(rounds: editAgentRounds, chat: chat)
- }
- if !text.isEmpty {
- Group{
- ThemedMarkdownText(text: text, chat: chat)
+ if editAgentRounds.count > 0 {
+ ProgressAgentRound(rounds: editAgentRounds, chat: chat, isStreaming: isThinkingStreaming())
}
- .scaledPadding(.leading, 2)
- .scaledPadding(.vertical, 4)
- }
-
- if let codeReviewRound = codeReviewRound {
- CodeReviewMainView(
- store: chat, round: codeReviewRound
- )
- .frame(maxWidth: .infinity)
- }
- if !errorMessages.isEmpty {
- VStack(spacing: 4) {
- ForEach(errorMessages.indices, id: \.self) { index in
- if let attributedString = try? AttributedString(markdown: errorMessages[index]) {
- NotificationBanner(style: .warning) {
- Text(attributedString)
+ if !text.isEmpty {
+ Group{
+ ThemedMarkdownText(text: text, chat: chat)
+ }
+ .scaledPadding(.leading, 2)
+ .scaledPadding(.vertical, 4)
+ }
+
+ if let codeReviewRound = codeReviewRound {
+ CodeReviewMainView(
+ store: chat, round: codeReviewRound
+ )
+ .frame(maxWidth: .infinity)
+ }
+
+ if !errorMessages.isEmpty {
+ buildErrorMessageView()
+ }
+
+ HStack {
+ if shouldShowTurnStatus() {
+ TurnStatusView(
+ message: message,
+ isSummarizingConversation: chat.isSummarizingConversation
+ )
+ .modify { view in
+ if message.turnStatus == .inProgress {
+ view
+ .scaledPadding(.leading, 6)
+ } else {
+ view
+ }
}
- }
}
+
+ Spacer()
+
+ ResponseToolBar(
+ id: id,
+ chat: chat,
+ text: text,
+ message: message
+ )
+ .conditionalFontWeight(.medium)
+ .opacity(shouldShowToolBar() ? 1 : 0)
+ .scaledPadding(.trailing, -20)
}
- .scaledPadding(.vertical, 4)
}
-
- HStack {
- if shouldShowTurnStatus() {
- TurnStatusView(message: message)
+ .padding(.leading, message.parentTurnId != nil ? 4 : 0)
+ .shadow(color: .black.opacity(0.05), radius: 6)
+ .contextMenu {
+ Button("Copy") {
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(text, forType: .string)
}
+ .scaledFont(.body)
- Spacer()
+ Button("Set as Extra System Prompt") {
+ chat.send(.setAsExtraPromptButtonTapped(id))
+ }
+ .scaledFont(.body)
- ResponseToolBar(id: id, chat: chat, text: text)
- .conditionalFontWeight(.medium)
- .opacity(shouldShowToolBar() ? 1 : 0)
- .scaledPadding(.trailing, -20)
- }
- }
- .shadow(color: .black.opacity(0.05), radius: 6)
- .contextMenu {
- Button("Copy") {
- NSPasteboard.general.clearContents()
- NSPasteboard.general.setString(text, forType: .string)
- }
- .scaledFont(.body)
-
- Button("Set as Extra System Prompt") {
- chat.send(.setAsExtraPromptButtonTapped(id))
+ Divider()
+
+ Button("Delete") {
+ chat.send(.deleteMessageButtonTapped(id))
+ }
+ .scaledFont(.body)
}
- .scaledFont(.body)
-
- Divider()
-
- Button("Delete") {
- chat.send(.deleteMessageButtonTapped(id))
+ .onHover {
+ isHovering = $0
}
- .scaledFont(.body)
}
- .onHover {
- isHovering = $0
+ }
+ }
+
+ @ViewBuilder
+ private func buildErrorMessageView() -> some View {
+ VStack(spacing: 4) {
+ ForEach(errorMessages.indices, id: \.self) { index in
+ if let attributedString = try? AttributedString(markdown: errorMessages[index]) {
+ NotificationBanner(style: .warning) {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(attributedString)
+
+ if isSettingsActionableError(errorMessages[index]) {
+ Button(action: {
+ Task {
+ try? launchHostAppAdvancedSettings()
+ }
+ }) {
+ Text("Open Settings")
+ }
+ .buttonStyle(.link)
+ }
+ }
+ }
+ }
}
}
+ .scaledPadding(.vertical, 4)
}
+ private func isSettingsActionableError(_ message: String) -> Bool {
+ message == HardCodedToolRoundExceedErrorMessage ||
+ message == SSLCertificateErrorMessage
+ }
+
private func shouldShowTurnStatus() -> Bool {
guard isLatestAssistantMessage() else {
return false
@@ -265,88 +249,28 @@ struct BotMessage: View {
let lastMessage = chat.history.last
return lastMessage?.role == .assistant && lastMessage?.id == id
}
-}
-
-struct ReferenceList: View {
- let references: [ConversationReference]
- let chat: StoreOf
-
- private let maxVisibleItems: Int = 6
- @State private var itemHeight: CGFloat = 16
-
- @AppStorage(\.chatFontSize) var chatFontSize
-
- struct ReferenceView: View {
- let references: [ConversationReference]
- let chat: StoreOf
- @AppStorage(\.chatFontSize) var chatFontSize
- @Binding var itemHeight: CGFloat
-
- var body: some View {
- VStack(alignment: .leading, spacing: 0) {
- ForEach(0.. Bool {
+ guard isLatestAssistantMessage(), chat.isReceivingMessage else { return false }
+ switch message.turnStatus {
+ case .success, .error, .cancelled: return false
+ default: return true
}
- .frame(maxWidth: .infinity, maxHeight: maxViewHeight)
-
- }
-
- private var maxViewHeight: CGFloat {
- let totalHeight = CGFloat(references.count) * itemHeight
- let maxHeight = CGFloat(maxVisibleItems) * itemHeight
- return min(totalHeight, maxHeight)
}
}
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
@@ -359,8 +283,6 @@ private struct TurnStatusView: View {
case .waitForConfirmation:
waitForConfirmationStatus
}
-
- Spacer()
}
}
}
@@ -369,14 +291,27 @@ private struct TurnStatusView: View {
HStack(spacing: 4) {
ProgressView()
.controlSize(.small)
- .scaledFont(size: chatFontSize - 1)
- .conditionalFontWeight(.medium)
-
+ .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")
@@ -468,47 +403,3 @@ struct BotMessage_Previews: PreviewProvider {
.fixedSize(horizontal: true, vertical: true)
}
}
-
-struct ReferenceList_Previews: PreviewProvider {
- static var previews: some View {
- let chatTabInfo = ChatTabInfo(id: "id", workspacePath: "path", username: "name")
- ReferenceList(references: [
- .init(
- uri: "/Core/Sources/ConversationTab/Views/BotMessage.swift",
- status: .included,
- kind: .class,
- referenceType: .file
- ),
- .init(
- uri: "/Core/Sources/ConversationTab/Views",
- status: .included,
- kind: .struct,
- referenceType: .file
- ),
- .init(
- uri: "/Core/Sources/ConversationTab/Views/BotMessage.swift",
- status: .included,
- kind: .function,
- referenceType: .file
- ),
- .init(
- uri: "/Core/Sources/ConversationTab/Views/BotMessage.swift",
- status: .included,
- kind: .case,
- referenceType: .file
- ),
- .init(
- uri: "/Core/Sources/ConversationTab/Views/BotMessage.swift",
- status: .included,
- kind: .extension,
- referenceType: .file
- ),
- .init(
- uri: "/Core/Sources/ConversationTab/Views/BotMessage.swift",
- status: .included,
- kind: .webpage,
- referenceType: .file
- ),
- ], chat: .init(initialState: .init(), reducer: { Chat(service: ChatService.service(for: chatTabInfo)) }))
- }
-}
diff --git a/Core/Sources/ConversationTab/Views/BotMessage/ResponseToolBar.swift b/Core/Sources/ConversationTab/Views/BotMessage/ResponseToolBar.swift
new file mode 100644
index 00000000..fa9a0bb1
--- /dev/null
+++ b/Core/Sources/ConversationTab/Views/BotMessage/ResponseToolBar.swift
@@ -0,0 +1,75 @@
+import SwiftUI
+import ComposableArchitecture
+import SharedUIComponents
+
+struct ResponseToolBar: View {
+ let id: String
+ let chat: StoreOf
+ let text: String
+ let message: DisplayedChatMessage
+ @AppStorage(\.chatFontSize) var chatFontSize
+
+ var billingMultiplier: String? {
+ guard let multiplier = message.billingMultiplier else {
+ return nil
+ }
+ let rounded = (multiplier * 100).rounded() / 100
+ 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"
+ }
+
+ var modelNameAndMultiplierText: String? {
+ 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
+ }
+
+ var body: some View {
+ HStack(spacing: 8) {
+
+ if let modelNameAndMultiplierText = modelNameAndMultiplierText {
+ Text(modelNameAndMultiplierText)
+ .scaledFont(size: chatFontSize - 1)
+ .lineLimit(1)
+ .foregroundColor(.secondary)
+ .help(modelNameAndMultiplierText)
+ }
+
+ UpvoteButton { rating in
+ chat.send(.upvote(id, rating))
+ }
+
+ DownvoteButton { rating in
+ chat.send(.downvote(id, rating))
+ }
+
+ CopyButton {
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(text, forType: .string)
+ chat.send(.copyCode(id))
+ }
+ }
+ }
+}
diff --git a/Core/Sources/ConversationTab/Views/ChatPanelInputArea/ChatPanelInputArea.swift b/Core/Sources/ConversationTab/Views/ChatPanelInputArea/ChatPanelInputArea.swift
index 251f4022..346981ef 100644
--- a/Core/Sources/ConversationTab/Views/ChatPanelInputArea/ChatPanelInputArea.swift
+++ b/Core/Sources/ConversationTab/Views/ChatPanelInputArea/ChatPanelInputArea.swift
@@ -19,22 +19,15 @@ struct ChatPanelInputArea: View {
Button(action: {
chat.send(.clearButtonTap)
}) {
- Group {
- if #available(macOS 13.0, *) {
- Image(systemName: "eraser.line.dashed.fill")
- .scaledFont(.body)
- } else {
- Image(systemName: "trash.fill")
- .scaledFont(.body)
+ Image(systemName: "eraser.line.dashed.fill")
+ .scaledFont(.body)
+ .padding(6)
+ .background {
+ Circle().fill(Color(nsColor: .controlBackgroundColor))
+ }
+ .overlay {
+ Circle().stroke(Color(nsColor: .controlColor), lineWidth: 1)
}
- }
- .padding(6)
- .background {
- Circle().fill(Color(nsColor: .controlBackgroundColor))
- }
- .overlay {
- Circle().stroke(Color(nsColor: .controlColor), lineWidth: 1)
- }
}
.buttonStyle(.plain)
}
diff --git a/Core/Sources/ConversationTab/Views/ChatPanelInputArea/ContextSizeButton.swift b/Core/Sources/ConversationTab/Views/ChatPanelInputArea/ContextSizeButton.swift
new file mode 100644
index 00000000..fdf3be11
--- /dev/null
+++ b/Core/Sources/ConversationTab/Views/ChatPanelInputArea/ContextSizeButton.swift
@@ -0,0 +1,196 @@
+import ConversationServiceProvider
+import SharedUIComponents
+import SwiftUI
+
+struct ContextSizeButton: View {
+ let contextSizeInfo: ContextSizeInfo
+ @State private var isHovering = false
+ @State private var showPopover = false
+ @State private var isClickTriggered = false
+ @State private var hoverTask: Task?
+ @State private var dismissTask: Task?
+
+ private let ringSize: CGFloat = 11
+ private let lineWidth: CGFloat = 1.5
+
+ var body: some View {
+ Button(action: {
+ hoverTask?.cancel()
+ dismissTask?.cancel()
+ isClickTriggered = true
+ showPopover = true
+ }) {
+ HStack(spacing: 4) {
+ DonutChart(
+ percentage: contextSizeInfo.utilizationPercentage,
+ ringColor: ringColor,
+ size: ringSize,
+ lineWidth: lineWidth
+ )
+
+ if isHovering {
+ Text("\(Int(contextSizeInfo.utilizationPercentage))%")
+ .scaledFont(size: 11, weight: .medium)
+ .foregroundColor(.primary)
+ .transition(.opacity)
+ }
+ }
+ .scaledPadding(.horizontal, 6)
+ .scaledPadding(.vertical, 4)
+ }
+ .buttonStyle(HoverButtonStyle(padding: 0))
+ .accessibilityLabel("Context size")
+ .accessibilityValue(Text("\(Int(contextSizeInfo.utilizationPercentage)) percent of context tokens used"))
+ .accessibilityHint("Shows details about the current context size and token usage.")
+ .animation(.easeInOut(duration: 0.15), value: isHovering)
+ .onHover { hovering in
+ isHovering = hovering
+ hoverTask?.cancel()
+ if hovering {
+ dismissTask?.cancel()
+ hoverTask = Task {
+ try? await Task.sleep(nanoseconds: 2_000_000_000)
+ guard !Task.isCancelled else { return }
+ isClickTriggered = false
+ showPopover = true
+ }
+ } else if !isClickTriggered {
+ scheduleDismiss()
+ }
+ }
+ .onChange(of: showPopover) { newValue in
+ if !newValue {
+ isClickTriggered = false
+ }
+ }
+ .popover(isPresented: $showPopover, arrowEdge: .bottom) {
+ ContextSizePopover(info: contextSizeInfo)
+ .onHover { hovering in
+ if hovering {
+ dismissTask?.cancel()
+ } else if !isClickTriggered {
+ scheduleDismiss()
+ }
+ }
+ }
+ }
+
+ private func scheduleDismiss() {
+ dismissTask?.cancel()
+ dismissTask = Task {
+ try? await Task.sleep(nanoseconds: 200_000_000)
+ guard !Task.isCancelled else { return }
+ showPopover = false
+ }
+ }
+
+ private var ringColor: Color {
+ let pct = contextSizeInfo.utilizationPercentage
+ if pct >= 80 { return Color("WarningYellow") }
+ return .secondary
+ }
+}
+
+private struct DonutChart: View {
+ let percentage: Double
+ let ringColor: Color
+ let size: CGFloat
+ let lineWidth: CGFloat
+
+ var body: some View {
+ ZStack {
+ Circle()
+ .stroke(Color(nsColor: .quaternaryLabelColor), lineWidth: lineWidth)
+
+ Circle()
+ .trim(from: 0, to: min(percentage / 100, 1.0))
+ .stroke(ringColor, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
+ .rotationEffect(.degrees(-90))
+ }
+ .scaledFrame(width: size, height: size)
+ }
+}
+
+private struct ContextSizePopover: View {
+ let info: ContextSizeInfo
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ // MARK: Context Window
+ VStack(alignment: .leading, spacing: 6) {
+ Text("Context Window")
+ .scaledFont(.headline)
+
+ HStack {
+ Text("\(formatTokens(info.totalUsedTokens)) / \(formatTokens(info.totalTokenLimit)) tokens")
+ Spacer()
+ Text(formatPercentage(info.utilizationPercentage))
+ }
+ .scaledFont(.callout)
+
+ ProgressView(value: min(info.utilizationPercentage, 100), total: 100)
+ .tint(progressColor)
+ }
+
+ // MARK: System
+ VStack(alignment: .leading, spacing: 4) {
+ Text("System")
+ .scaledFont(.headline)
+ .scaledPadding(.bottom, 4)
+
+ tokenRow("System Instructions", tokens: info.systemPromptTokens)
+ tokenRow("Tool Definitions", tokens: info.toolDefinitionTokens)
+ }
+
+ // MARK: User
+ VStack(alignment: .leading, spacing: 4) {
+ Text("User")
+ .scaledFont(.headline)
+ .scaledPadding(.bottom, 4)
+
+ tokenRow("Messages", tokens: info.userMessagesTokens + info.assistantMessagesTokens)
+ tokenRow("Attached Files", tokens: info.attachedFilesTokens)
+ tokenRow("Tool Results", tokens: info.toolResultsTokens)
+ }
+
+ // TODO: Depends on CLS for manual compression
+ }
+ .scaledPadding(.vertical, 20)
+ .scaledPadding(.horizontal, 16)
+ .scaledFrame(width: 240)
+ }
+
+ private var progressColor: Color {
+ let pct = info.utilizationPercentage
+ if pct >= 80 { return Color(nsColor: .systemYellow) }
+ return .accentColor
+ }
+
+ private func tokenRow(_ label: String, tokens: Int) -> some View {
+ HStack {
+ Text(label)
+ Spacer()
+ Text(percentage(for: tokens))
+ }
+ .scaledFont(.callout)
+ }
+
+ private func percentage(for tokens: Int) -> String {
+ guard info.totalTokenLimit > 0 else { return "0%" }
+ let pct = Double(tokens) / Double(info.totalTokenLimit) * 100
+ return formatPercentage(pct)
+ }
+
+ private func formatPercentage(_ pct: Double) -> String {
+ if pct == 0 { return "0%" }
+ return String(format: "%.1f%%", pct)
+ }
+
+ private func formatTokens(_ count: Int) -> String {
+ if count >= 1000 {
+ let k = Double(count) / 1000.0
+ return String(format: "%.1fK", k)
+ }
+ return "\(count)"
+ }
+}
diff --git a/Core/Sources/ConversationTab/Views/ChatPanelInputArea/InputAreaTextEditor.swift b/Core/Sources/ConversationTab/Views/ChatPanelInputArea/InputAreaTextEditor.swift
index c9addc8b..0659dbf8 100644
--- a/Core/Sources/ConversationTab/Views/ChatPanelInputArea/InputAreaTextEditor.swift
+++ b/Core/Sources/ConversationTab/Views/ChatPanelInputArea/InputAreaTextEditor.swift
@@ -35,6 +35,7 @@ struct InputAreaTextEditor: View {
)
@ObservedObject private var status: StatusObserver = .shared
@State private var isCCRFFEnabled: Bool
+ @State private var isCCRHovering: Bool = false
@State private var cancellables = Set()
@StateObject private var fontScaleManager = FontScaleManager.shared
@@ -78,13 +79,17 @@ struct InputAreaTextEditor: View {
return false
}
-
- var typedMessage: String {
- chat.state.getChatContext(of: editorMode).typedMessage
+
+ var projectRootURL: URL? {
+ WorkspaceXcodeWindowInspector.extractProjectURL(
+ workspaceURL: chat.workspaceURL,
+ documentURL: chat.state.currentEditor?.url
+ )
}
var body: some View {
WithPerceptionTracking {
+ let typedMessage = chat.state.getChatContext(of: editorMode).typedMessage
VStack(spacing: 0) {
chatContextView
@@ -165,25 +170,43 @@ struct InputAreaTextEditor: View {
.padding(.top, 4)
HStack(spacing: 0) {
- ModelPicker()
-
+ ModeAndModelPicker(projectRootURL: projectRootURL, selectedAgent: $chat.selectedAgent)
+
Spacer()
-
- if chat.editorMode.isDefault {
+
+ if let contextSizeInfo = chat.contextSizeInfo {
+ ContextSizeButton(contextSizeInfo: contextSizeInfo)
+ .padding(.trailing, 4)
+ }
+
+ if chat.editorMode.isDefault && !isRequestingConversation {
codeReviewButton
- .buttonStyle(HoverButtonStyle(padding: 0))
- .disabled(isRequestingConversation)
+ .buttonStyle(HoverButtonStyle(padding: 0, hoverColor: .clear))
+ .padding(.trailing, 4)
}
-
+
ZStack {
sendButton
- .opacity(isRequestingConversation ? 0 : 1)
-
+ .opacity(isRequestingConversation || isRequestingCodeReview ? 0 : 1)
+ .foregroundColor(
+ typedMessage.isEmpty ? Color(nsColor: .tertiaryLabelColor) : Color(
+ "IconStrokeColor"
+ )
+ )
+ .disabled(typedMessage.isEmpty)
+
stopButton
- .opacity(isRequestingConversation ? 1 : 0)
+ .opacity(isRequestingConversation || isRequestingCodeReview ? 1 : 0)
+ .foregroundColor(Color("IconStrokeColor"))
}
- .buttonStyle(HoverButtonStyle(padding: 0))
- .disabled(isRequestingCodeReview)
+ .buttonStyle(
+ HoverButtonStyle(
+ padding: 0,
+ hoverColor: Color(nsColor: .quaternaryLabelColor),
+ backgroundColor: Color(nsColor: .quinaryLabel),
+ cornerRadius: .infinity
+ )
+ )
}
.padding(8)
.padding(.top, -4)
@@ -286,9 +309,12 @@ struct InputAreaTextEditor: View {
Button(action: {
submitChatMessage()
}) {
- Image(systemName: "paperplane.fill")
- .scaledFont(.body)
- .padding(4)
+ Image(systemName: "paperplane")
+ .scaledFont(size: 12, weight: .medium)
+ .padding(.leading, 5)
+ .padding(.trailing, 6)
+ .padding(.top, 6.5)
+ .padding(.bottom, 5.5)
}
.keyboardShortcut(KeyEquivalent.return, modifiers: [])
.help("Send")
@@ -298,10 +324,12 @@ struct InputAreaTextEditor: View {
Button(action: {
chat.send(.stopRespondingButtonTapped)
}) {
- Image(systemName: "stop.circle")
- .scaledFont(.body)
- .padding(4)
+ Image(systemName: "stop.fill")
+ .scaledFont(size: 12, weight: .medium)
+ .padding(8)
}
+ .keyboardShortcut(KeyEquivalent.escape, modifiers: [])
+ .help("Stop")
}
private var isFreeUser: Bool {
@@ -331,31 +359,28 @@ struct InputAreaTextEditor: View {
if isFreeUser {
// Show nothing
} else if isCCRFFEnabled {
- ZStack {
- stopButton
- .opacity(isRequestingCodeReview ? 1 : 0)
- .help("Stop Code Review")
-
- Menu {
- Button(action: {
- chat.send(.codeReview(.request(.index)))
- }) {
- Text("Review Staged Changes")
- }
-
- Button(action: {
- chat.send(.codeReview(.request(.workingTree)))
- }) {
- Text("Review Unstaged Changes")
- }
- } label: {
- codeReviewIcon
+ Menu {
+ Button(action: {
+ chat.send(.codeReview(.request(.index)))
+ }) {
+ Text("Review Staged Changes")
}
- .scaledFont(.body)
- .opacity(isRequestingCodeReview ? 0 : 1)
- .help("Code Review")
+
+ Button(action: {
+ chat.send(.codeReview(.request(.workingTree)))
+ }) {
+ Text("Review Unstaged Changes")
+ }
+ } label: {
+ codeReviewIcon
+ .foregroundColor(isCCRHovering ? .primary : Color("IconStrokeColor"))
}
- .buttonStyle(HoverButtonStyle(padding: 0))
+ .scaledFont(.body)
+ .onHover { hovering in
+ isCCRHovering = hovering
+ }
+ .opacity(isRequestingCodeReview ? 0 : 1)
+ .help("Code Review")
} else {
codeReviewIcon
.foregroundColor(Color(nsColor: .tertiaryLabelColor))
@@ -460,7 +485,7 @@ struct InputAreaTextEditor: View {
}
}()
- HStack(spacing: 0) {
+ HStack(alignment: .center, spacing: 0) {
makeContextFileNameView(url: ref.url, isCurrentEditor: true, selection: ref.selection)
Toggle("", isOn: $isCurrentEditorContextEnabled)
@@ -500,7 +525,6 @@ struct InputAreaTextEditor: View {
selection: LSPRange? = nil
) -> some View {
drawFileIcon(url, isDirectory: isDirectory)
- .resizable()
.scaledToFit()
.scaledFrame(width: 16, height: 16)
.foregroundColor(.primary.opacity(0.85))
diff --git a/Core/Sources/ConversationTab/Views/CodeReviewRound/FileSelectionSection.swift b/Core/Sources/ConversationTab/Views/CodeReviewRound/FileSelectionSection.swift
index 9686afd3..bc044fa7 100644
--- a/Core/Sources/ConversationTab/Views/CodeReviewRound/FileSelectionSection.swift
+++ b/Core/Sources/ConversationTab/Views/CodeReviewRound/FileSelectionSection.swift
@@ -255,11 +255,10 @@ private struct FileSelectionRow: View {
}
var body: some View {
- HStack {
+ HStack(alignment: .center) {
Toggle(isOn: $isSelected) {
HStack(spacing: 8) {
drawFileIcon(fileURL)
- .resizable()
.scaledToFit()
.scaledFrame(width: 16, height: 16)
diff --git a/Core/Sources/ConversationTab/Views/CodeReviewRound/ReviewResultsSection.swift b/Core/Sources/ConversationTab/Views/CodeReviewRound/ReviewResultsSection.swift
index 67dcf282..1a239021 100644
--- a/Core/Sources/ConversationTab/Views/CodeReviewRound/ReviewResultsSection.swift
+++ b/Core/Sources/ConversationTab/Views/CodeReviewRound/ReviewResultsSection.swift
@@ -147,9 +147,8 @@ private struct ReviewResultRowContent: View {
@AppStorage(\.chatFontSize) private var chatFontSize
var body: some View {
- HStack(spacing: 4) {
+ HStack(alignment: .center, spacing: 4) {
drawFileIcon(fileURL)
- .resizable()
.scaledToFit()
.scaledFrame(width: 16, height: 16)
diff --git a/Core/Sources/ConversationTab/Views/ConversationAgentProgressView.swift b/Core/Sources/ConversationTab/Views/ConversationAgentProgressView.swift
deleted file mode 100644
index 39c8ccc4..00000000
--- a/Core/Sources/ConversationTab/Views/ConversationAgentProgressView.swift
+++ /dev/null
@@ -1,229 +0,0 @@
-import SwiftUI
-import ConversationServiceProvider
-import ComposableArchitecture
-import Combine
-import ChatTab
-import ChatService
-import SharedUIComponents
-
-struct ProgressAgentRound: View {
- let rounds: [AgentRound]
- let chat: StoreOf
-
- var body: some View {
- WithPerceptionTracking {
- VStack(alignment: .leading, spacing: 8) {
- ForEach(rounds, id: \.roundId) { round in
- VStack(alignment: .leading, spacing: 8) {
- ThemedMarkdownText(text: round.reply, chat: chat)
- if let toolCalls = round.toolCalls, !toolCalls.isEmpty {
- ProgressToolCalls(tools: toolCalls, chat: chat)
- }
- }
- }
- }
- .foregroundStyle(.secondary)
- }
- }
-}
-
-struct ProgressToolCalls: View {
- let tools: [AgentToolCall]
- let chat: StoreOf
-
- var body: some View {
- WithPerceptionTracking {
- VStack(alignment: .leading, spacing: 4) {
- ForEach(tools) { tool in
- if tool.name == ToolName.runInTerminal.rawValue && tool.invokeParams != nil {
- RunInTerminalToolView(tool: tool, chat: chat)
- } else if tool.invokeParams != nil && tool.status == .waitForConfirmation {
- ToolConfirmationView(tool: tool, chat: chat)
- } else {
- ToolStatusItemView(tool: tool)
- }
- }
- }
- }
- }
-}
-
-struct ToolConfirmationView: View {
- let tool: AgentToolCall
- let chat: StoreOf
-
- @AppStorage(\.chatFontSize) var chatFontSize
-
- var body: some View {
- WithPerceptionTracking {
- VStack(alignment: .leading, spacing: 8) {
- GenericToolTitleView(toolStatus: "Run", toolName: tool.name, fontWeight: .semibold)
-
- ThemedMarkdownText(text: tool.invokeParams?.message ?? "", chat: chat)
- .frame(maxWidth: .infinity, alignment: .leading)
-
- HStack {
- Button(action: {
- chat.send(.toolCallCancelled(tool.id))
- }) {
- Text("Skip")
- .scaledFont(.body)
- }
-
- Button(action: {
- chat.send(.toolCallAccepted(tool.id))
- }) {
- Text("Allow")
- .scaledFont(.body)
- }
- .buttonStyle(BorderedProminentButtonStyle())
-
- }
- .frame(maxWidth: .infinity, alignment: .leading)
- .scaledPadding(.top, 4)
- }
- .scaledPadding(8)
- .cornerRadius(8)
- .overlay(
- RoundedRectangle(cornerRadius: 8)
- .stroke(Color.gray.opacity(0.2), lineWidth: 1)
- )
- }
- }
-}
-
-struct GenericToolTitleView: View {
- var toolStatus: String
- var toolName: String
- var fontWeight: Font.Weight = .regular
-
- @AppStorage(\.chatFontSize) var chatFontSize
-
- var body: some View {
- HStack(spacing: 4) {
- Text(toolStatus)
- .textSelection(.enabled)
- .scaledFont(size: chatFontSize, weight: fontWeight)
- .foregroundStyle(.primary)
- .background(Color.clear)
- Text(toolName)
- .textSelection(.enabled)
- .scaledFont(size: chatFontSize, weight: fontWeight)
- .foregroundStyle(.primary)
- .scaledPadding(.vertical, 2)
- .scaledPadding(.horizontal, 4)
- .background(Color("ToolTitleHighlightBgColor"))
- .cornerRadius(4)
- .overlay(
- RoundedRectangle(cornerRadius: 4)
- .inset(by: 0.5)
- .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
- )
- }
- .frame(maxWidth: .infinity, alignment: .leading)
- }
-}
-
-struct ToolStatusItemView: View {
-
- let tool: AgentToolCall
-
- @AppStorage(\.chatFontSize) var chatFontSize
-
- var statusIcon: some View {
- Group {
- switch tool.status {
- case .running:
- ProgressView()
- .controlSize(.small)
- .scaledScaleEffect(0.7)
- case .completed:
- Image(systemName: "checkmark")
- .foregroundColor(Color.successLightGreen)
- case .error:
- Image(systemName: "xmark.circle")
- .foregroundColor(.red.opacity(0.5))
- case .cancelled:
- Image(systemName: "slash.circle")
- .foregroundColor(.gray.opacity(0.5))
- case .waitForConfirmation:
- EmptyView()
- case .accepted:
- EmptyView()
- }
- }
- .scaledFont(size: chatFontSize - 1, weight: .medium)
- }
-
- var progressTitleText: some View {
- let message: String = {
- var msg = tool.progressMessage ?? ""
- if tool.name == ToolName.createFile.rawValue {
- if let input = tool.invokeParams?.input, let filePath = input["filePath"]?.value as? String {
- let fileURL = URL(fileURLWithPath: filePath)
- msg += ": [\(fileURL.lastPathComponent)](\(fileURL.absoluteString))"
- }
- }
- return msg
- }()
-
- return Group {
- if message.isEmpty {
- GenericToolTitleView(toolStatus: "Running", toolName: tool.name)
- } else {
- if let attributedString = try? AttributedString(markdown: message) {
- Text(attributedString)
- .environment(\.openURL, OpenURLAction { url in
- if url.scheme == "file" || url.isFileURL {
- NSWorkspace.shared.open(url)
- return .handled
- } else {
- return .systemAction
- }
- })
- } else {
- Text(message)
- }
- }
- }
- }
-
- var body: some View {
- WithPerceptionTracking {
- HStack(spacing: 4) {
- statusIcon
- .scaledFrame(width: 16, height: 16)
-
- progressTitleText
- .scaledFont(size: chatFontSize)
- .lineLimit(1)
-
- Spacer()
- }
- }
- }
-}
-
-struct ProgressAgentRound_Preview: PreviewProvider {
- static let agentRounds: [AgentRound] = [
- .init(roundId: 1, reply: "this is agent step", toolCalls: [
- .init(
- id: "toolcall_001",
- name: "Tool Call 1",
- progressMessage: "Read Tool Call 1",
- status: .completed,
- error: nil),
- .init(
- id: "toolcall_002",
- name: "Tool Call 2",
- progressMessage: "Running Tool Call 2",
- status: .running)
- ])
- ]
-
- static var previews: some View {
- let chatTabInfo = ChatTabInfo(id: "id", workspacePath: "path", username: "name")
- ProgressAgentRound(rounds: agentRounds, chat: .init(initialState: .init(), reducer: { Chat(service: ChatService.service(for: chatTabInfo)) }))
- .frame(width: 300, height: 300)
- }
-}
diff --git a/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ConversationAgentProgressView.swift b/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ConversationAgentProgressView.swift
new file mode 100644
index 00000000..f002f643
--- /dev/null
+++ b/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ConversationAgentProgressView.swift
@@ -0,0 +1,456 @@
+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(Array(rounds.enumerated()), id: \.element.roundId) { roundIndex, round in
+ let isLastRound = roundIndex == rounds.count - 1
+ VStack(alignment: .leading, spacing: 8) {
+ 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)
+ }
+ if let subAgentRounds = round.subAgentRounds, !subAgentRounds.isEmpty {
+ SubAgentRounds(rounds: subAgentRounds, chat: chat)
+ }
+ }
+ }
+ }
+ .foregroundStyle(.secondary)
+ }
+ }
+}
+
+struct SubAgentRounds: View {
+ let rounds: [AgentRound]
+ let chat: StoreOf
+
+ @Environment(\.colorScheme) var colorScheme
+
+ var body: some View {
+ WithPerceptionTracking {
+ VStack(alignment: .leading, spacing: 8) {
+ ForEach(rounds, id: \.roundId) { round in
+ VStack(alignment: .leading, spacing: 8) {
+ 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)
+ }
+ }
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .scaledPadding(.horizontal, 16)
+ .scaledPadding(.vertical, 12)
+ .background(RoundedRectangle(cornerRadius: 8).fill(Color("SubagentTurnBackground")))
+ }
+ }
+}
+
+struct ProgressToolCalls: View {
+ let tools: [AgentToolCall]
+ let chat: StoreOf
+
+ var body: some View {
+ WithPerceptionTracking {
+ VStack(alignment: .leading, spacing: 4) {
+ ForEach(tools) { tool in
+ if tool.name == ToolName.runInTerminal.rawValue && (tool.invokeParams != nil || tool.input != nil) {
+ RunInTerminalToolView(tool: tool, chat: chat)
+ } else if tool.invokeParams != nil && tool.status == .waitForConfirmation {
+ ToolConfirmationView(tool: tool, chat: chat)
+ } else if tool.isToolcallingLoopContinueTool {
+ // ignore rendering for internal tool calling loop continue tool
+ } else {
+ ToolStatusItemView(tool: tool)
+ }
+ }
+ }
+ }
+ }
+}
+
+struct ToolConfirmationView: View {
+ let tool: AgentToolCall
+ let chat: StoreOf
+
+ @AppStorage(\.chatFontSize) var chatFontSize
+
+ private var toolName: String { tool.name }
+ private var titleText: String { tool.title ?? "" }
+ private var mcpServerName: String? { ToolAutoApprovalManager.extractMCPServerName(from: titleText) }
+ private var conversationId: String { tool.invokeParams?.conversationId ?? "" }
+ private var invokeMessage: String { tool.invokeParams?.message ?? "" }
+ private var isSensitiveFileOperation: Bool { ToolAutoApprovalManager.isSensitiveFileOperation(message: invokeMessage) }
+ private var sensitiveFileInfo: ToolAutoApprovalManager.SensitiveFileConfirmationInfo {
+ ToolAutoApprovalManager.extractSensitiveFileConfirmationInfo(from: invokeMessage)
+ }
+
+ private var shouldShowMCPSplitButton: Bool { mcpServerName != nil && !conversationId.isEmpty }
+ private var shouldShowSensitiveFileSplitButton: Bool {
+ mcpServerName == nil && isSensitiveFileOperation && !conversationId.isEmpty
+ }
+
+ @ViewBuilder
+ private var confirmationActionView: some View {
+ if FeatureFlagNotifierImpl.shared.featureFlags.agentModeAutoApproval &&
+ CopilotPolicyNotifierImpl.shared.copilotPolicy.agentModeAutoApprovalEnabled {
+ if tool.isToolcallingLoopContinueTool {
+ continueButton
+ } else if shouldShowSensitiveFileSplitButton {
+ sensitiveFileSplitButton
+ } else if shouldShowMCPSplitButton, let serverName = mcpServerName {
+ mcpSplitButton(serverName: serverName)
+ } else {
+ allowButton
+ }
+ } else {
+ legacyAllowOrContinueButton
+ }
+ }
+
+ private var continueButton: some View {
+ Button(action: {
+ chat.send(.toolCallAccepted(tool.id))
+ }) {
+ Text("Continue")
+ .scaledFont(.body)
+ }
+ .buttonStyle(.borderedProminent)
+ }
+
+ private var allowButton: some View {
+ Button(action: {
+ chat.send(.toolCallAccepted(tool.id))
+ }) {
+ Text("Allow")
+ .scaledFont(.body)
+ }
+ .buttonStyle(.borderedProminent)
+ }
+
+ private var legacyAllowOrContinueButton: some View {
+ Button(action: {
+ chat.send(.toolCallAccepted(tool.id))
+ }) {
+ Text(tool.isToolcallingLoopContinueTool ? "Continue" : "Allow")
+ .scaledFont(.body)
+ }
+ .buttonStyle(.borderedProminent)
+ }
+
+ private var sensitiveFileMenuItems: [SplitButtonMenuItem] {
+ var items: [SplitButtonMenuItem] = []
+
+ items.append(
+ SplitButtonMenuItem(title: "Allow in this Session") {
+ chat.send(
+ .toolCallAcceptedWithApproval(
+ tool.id,
+ .sensitiveFile(
+ scope: .session(conversationId),
+ toolName: toolName,
+ description: sensitiveFileInfo.description,
+ pattern: sensitiveFileInfo.pattern
+ )
+ )
+ )
+ }
+ )
+
+ let defaultPatterns = ["**/.github/instructions/*", "**/github-copilot/**/*", "outside-workspace"]
+
+ if let pattern = sensitiveFileInfo.pattern, !pattern.isEmpty, !defaultPatterns.contains(pattern) {
+ items.append(
+ SplitButtonMenuItem(title: "Always Allow") {
+ chat.send(
+ .toolCallAcceptedWithApproval(
+ tool.id,
+ .sensitiveFile(
+ scope: .global,
+ toolName: toolName,
+ description: sensitiveFileInfo.description,
+ pattern: pattern
+ )
+ )
+ )
+ }
+ )
+ }
+
+ items.append(.divider())
+ items.append(
+ SplitButtonMenuItem(title: "Configure Auto Approve...") {
+ chat.send(.openAutoApproveSettings)
+ }
+ )
+
+ return items
+ }
+
+ private var sensitiveFileSplitButton: some View {
+ SplitButton(
+ title: "Allow",
+ isDisabled: false,
+ primaryAction: {
+ chat.send(.toolCallAccepted(tool.id))
+ },
+ menuItems: sensitiveFileMenuItems,
+ style: .prominent
+ )
+ }
+
+ private func mcpMenuItems(serverName: String) -> [SplitButtonMenuItem] {
+ var items: [SplitButtonMenuItem] = []
+
+ items.append(
+ SplitButtonMenuItem(title: "Allow \(toolName) in this Session") {
+ chat.send(
+ .toolCallAcceptedWithApproval(
+ tool.id,
+ .mcpTool(
+ scope: .session(conversationId),
+ serverName: serverName,
+ toolName: toolName
+ )
+ )
+ )
+ }
+ )
+
+ items.append(
+ SplitButtonMenuItem(title: "Always Allow \(toolName)") {
+ chat.send(
+ .toolCallAcceptedWithApproval(
+ tool.id,
+ .mcpTool(
+ scope: .global,
+ serverName: serverName,
+ toolName: toolName
+ )
+ )
+ )
+ }
+ )
+
+ items.append(.divider())
+
+ items.append(
+ SplitButtonMenuItem(title: "Allow tools from \(serverName) in this Session") {
+ chat.send(
+ .toolCallAcceptedWithApproval(
+ tool.id,
+ .mcpServer(
+ scope: .session(conversationId),
+ serverName: serverName
+ )
+ )
+ )
+ }
+ )
+
+ items.append(
+ SplitButtonMenuItem(title: "Always Allow tools from \(serverName)") {
+ chat.send(
+ .toolCallAcceptedWithApproval(
+ tool.id,
+ .mcpServer(
+ scope: .global,
+ serverName: serverName
+ )
+ )
+ )
+ }
+ )
+
+ items.append(.divider())
+
+ items.append(
+ SplitButtonMenuItem(title: "Configure Auto Approve...") {
+ chat.send(.openAutoApproveSettings)
+ }
+ )
+
+ return items
+ }
+
+ private func mcpSplitButton(serverName: String) -> some View {
+ SplitButton(
+ title: "Allow",
+ isDisabled: false,
+ primaryAction: {
+ chat.send(.toolCallAccepted(tool.id))
+ },
+ menuItems: mcpMenuItems(serverName: serverName),
+ style: .prominent
+ )
+ }
+
+ var body: some View {
+ WithPerceptionTracking {
+ VStack(alignment: .leading, spacing: 8) {
+ if let title = tool.title {
+ ToolConfirmationTitleView(title: title, fontWeight: .semibold)
+ } else {
+ GenericToolTitleView(toolStatus: "Run", toolName: tool.name, fontWeight: .semibold)
+ }
+
+ ThemedMarkdownText(text: tool.invokeParams?.message ?? "", chat: chat)
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ HStack {
+ Button(action: {
+ chat.send(.toolCallCancelled(tool.id))
+ }) {
+ Text(tool.isToolcallingLoopContinueTool ? "Cancel" : "Skip")
+ .scaledFont(.body)
+ }
+
+ confirmationActionView
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .scaledPadding(.top, 4)
+ }
+ .scaledPadding(8)
+ .cornerRadius(8)
+ .overlay(
+ RoundedRectangle(cornerRadius: 8)
+ .stroke(Color.gray.opacity(0.2), lineWidth: 1)
+ )
+ }
+ }
+}
+
+struct ToolConfirmationTitleView: View {
+ var title: String
+ var fontWeight: Font.Weight = .regular
+
+ @AppStorage(\.chatFontSize) var chatFontSize
+
+ var body: some View {
+ HStack(spacing: 4) {
+ Text(title)
+ .textSelection(.enabled)
+ .scaledFont(size: chatFontSize, weight: fontWeight)
+ .foregroundStyle(.primary)
+ .background(Color.clear)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+}
+
+struct GenericToolTitleView: View {
+ var toolStatus: String
+ var toolName: String
+ var fontWeight: Font.Weight = .regular
+
+ @AppStorage(\.chatFontSize) var chatFontSize
+
+ var body: some View {
+ HStack(spacing: 4) {
+ Text(toolStatus)
+ .textSelection(.enabled)
+ .scaledFont(size: chatFontSize - 1, weight: fontWeight)
+ .foregroundStyle(.primary)
+ .background(Color.clear)
+ Text(toolName)
+ .textSelection(.enabled)
+ .scaledFont(size: chatFontSize - 1, weight: fontWeight)
+ .foregroundStyle(.primary)
+ .scaledPadding(.vertical, 2)
+ .scaledPadding(.horizontal, 4)
+ .background(Color("ToolTitleHighlightBgColor"))
+ .cornerRadius(4)
+ .overlay(
+ RoundedRectangle(cornerRadius: 4)
+ .inset(by: 0.5)
+ .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
+ )
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+}
+
+struct ProgressAgentRound_Preview: PreviewProvider {
+ static let agentRounds: [AgentRound] = [
+ .init(roundId: 1, reply: "this is agent step", toolCalls: [
+ // Completed read file
+ .init(
+ id: "toolcall_001",
+ name: ServerToolName.readFile.rawValue,
+ progressMessage: "Read src/AppDelegate.swift",
+ status: .completed),
+ // Completed file search with results
+ .init(
+ id: "toolcall_002",
+ 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: 400, height: 500)
+ }
+}
diff --git a/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ExpandableFileListView.swift b/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ExpandableFileListView.swift
new file mode 100644
index 00000000..f5a95a2d
--- /dev/null
+++ b/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ExpandableFileListView.swift
@@ -0,0 +1,219 @@
+import SwiftUI
+import SharedUIComponents
+import AppKit
+import Terminal
+
+struct FileSearchResult: Hashable {
+ var file: String
+ var startLine: Int? = nil
+ var endLine: Int? = nil
+ var content: String? = nil
+}
+
+struct ExpandableFileListView: View {
+ var progressMessage: ProgressMessage
+ var files: [FileSearchResult]
+ var chatFontSize: Double
+ var helpText: String
+ var onFileClick: ((String) -> Void)? = nil
+ var fileHelpTexts: [String: String]? = nil
+
+ @State private var isExpanded: Bool = false
+
+ init(
+ progressMessage: ProgressMessage,
+ files: [FileSearchResult],
+ chatFontSize: Double,
+ helpText: String,
+ onFileClick: ((String) -> Void)? = nil,
+ fileHelpTexts: [String: String]? = nil
+ ) {
+ self.progressMessage = progressMessage
+ self.files = files
+ self.chatFontSize = chatFontSize
+ self.helpText = helpText
+ self.onFileClick = onFileClick
+ self.fileHelpTexts = fileHelpTexts
+ }
+
+ init(
+ progressMessage: ProgressMessage,
+ files: [String],
+ chatFontSize: Double,
+ helpText: String,
+ onFileClick: ((String) -> Void)? = nil,
+ fileHelpTexts: [String: String]? = nil
+ ) {
+ self.init(
+ progressMessage: progressMessage,
+ files: files.map { FileSearchResult(file: $0) },
+ chatFontSize: chatFontSize,
+ helpText: helpText,
+ onFileClick: onFileClick,
+ fileHelpTexts: fileHelpTexts
+ )
+ }
+
+ private let maxVisibleRows = 5
+ private let chevronWidth: CGFloat = 16
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ // Header with chevron on the left
+ Button(action: {
+ isExpanded.toggle()
+ }) {
+ HStack(spacing: 4) {
+ Image(systemName: isExpanded ? "chevron.down" : "chevron.right")
+ .resizable()
+ .scaledToFit()
+ .padding(4)
+ .scaledFrame(width: chevronWidth, height: chevronWidth)
+ .scaledFont(size: 10, weight: .medium)
+ .foregroundColor(.secondary)
+
+ progressMessage
+ .scaledFont(size: chatFontSize - 1)
+ .lineLimit(1)
+
+ Spacer()
+ }
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .help(helpText)
+
+ if isExpanded {
+ HStack(alignment: .top, spacing: 0) {
+ // Vertical line aligned with chevron center
+ Rectangle()
+ .fill(Color.secondary.opacity(0.3))
+ .scaledFrame(width: 1)
+ .scaledPadding(.leading, chevronWidth / 2 - 0.5)
+
+ // File list
+ VStack(alignment: .leading, spacing: 0) {
+ if files.count <= maxVisibleRows {
+ ForEach(files, id: \.self) { fileItem in
+ fileRow(for: fileItem)
+ }
+ } else {
+ ThinScrollView {
+ VStack(alignment: .leading, spacing: 0) {
+ ForEach(files, id: \.self) { fileItem in
+ fileRow(for: fileItem)
+ }
+ }
+ }
+ .frame(height: CGFloat(maxVisibleRows) * 23)
+ }
+ }
+ .scaledPadding(.leading, chevronWidth / 2)
+ }
+ .scaledPadding(.top, 4)
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func fileRow(for fileItem: FileSearchResult) -> some View {
+ let filePath = fileItem.file
+ let isDirectory = filePath.hasSuffix("/")
+ let cleanPath = isDirectory ? String(filePath.dropLast()) : filePath
+ let url = URL(string: cleanPath).flatMap { $0.scheme == "file" ? $0 : nil } ?? URL(fileURLWithPath: cleanPath)
+ let displayName: String = {
+ var name = isDirectory ? url.lastPathComponent + "/" : url.lastPathComponent
+ if let line = fileItem.startLine, !isDirectory {
+ name += ": \(line)"
+ if let endLine = fileItem.endLine {
+ name += "-\(endLine)"
+ }
+ }
+ return name
+ }()
+
+ Button(action: {
+ if let onFileClick = onFileClick {
+ onFileClick(filePath)
+ } else {
+ if let line = fileItem.startLine, !isDirectory {
+ Task {
+ let terminal = Terminal()
+ do {
+ _ = try await terminal.runCommand(
+ "/usr/bin/xed",
+ arguments: [
+ "-l",
+ String(line),
+ url.path
+ ],
+ environment: [
+ "TARGET_FILE": url.path
+ ]
+ )
+ } catch {
+ print("Failed to open file with xed: \(error)")
+ NSWorkspace.shared.open(url)
+ }
+ }
+ } else {
+ NSWorkspace.shared.open(url)
+ }
+ }
+ }) {
+ HStack(alignment: .center, spacing: 6) {
+ drawFileIcon(url, isDirectory: isDirectory)
+ .scaledToFit()
+ .scaledFrame(width: 13, height: 13)
+ .foregroundColor(.secondary)
+
+ Text(displayName)
+ .scaledFont(size: chatFontSize - 1)
+ .foregroundColor(.secondary)
+ .lineLimit(1)
+
+ Spacer()
+ }
+ .contentShape(Rectangle())
+ }
+ .help(fileHelpTexts?[filePath] ?? url.path)
+ .buttonStyle(HoverButtonStyle())
+ }
+}
+
+// NSScrollView wrapper for thin, overlay-style scrollbars
+struct ThinScrollView: NSViewRepresentable {
+ let content: Content
+
+ init(@ViewBuilder content: () -> Content) {
+ self.content = content()
+ }
+
+ func makeNSView(context: Context) -> NSScrollView {
+ let scrollView = NSScrollView()
+ scrollView.hasVerticalScroller = true
+ scrollView.hasHorizontalScroller = false
+ scrollView.autohidesScrollers = false
+ scrollView.scrollerStyle = .overlay
+ scrollView.drawsBackground = false
+ scrollView.borderType = .noBorder
+
+ let hostingView = NSHostingView(rootView: content)
+ scrollView.documentView = hostingView
+
+ // Ensure the hosting view can expand vertically
+ hostingView.translatesAutoresizingMaskIntoConstraints = false
+ NSLayoutConstraint.activate([
+ hostingView.widthAnchor.constraint(equalTo: scrollView.widthAnchor)
+ ])
+
+ return scrollView
+ }
+
+ func updateNSView(_ scrollView: NSScrollView, context: Context) {
+ if let hostingView = scrollView.documentView as? NSHostingView {
+ hostingView.rootView = content
+ hostingView.invalidateIntrinsicContentSize()
+ }
+ }
+}
diff --git a/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ToolStatusItemView.swift b/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ToolStatusItemView.swift
new file mode 100644
index 00000000..fc87e3bb
--- /dev/null
+++ b/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ToolStatusItemView.swift
@@ -0,0 +1,634 @@
+import SwiftUI
+import ConversationServiceProvider
+import SharedUIComponents
+import ComposableArchitecture
+import MarkdownUI
+
+struct ToolStatusItemView: View {
+
+ let tool: AgentToolCall
+
+ @AppStorage(\.chatFontSize) var chatFontSize
+ @AppStorage(\.fontScale) var fontScale
+
+ @State private var isHoveringFileLink = false
+
+ var statusIcon: some View {
+ Group {
+ switch tool.status {
+ case .running:
+ ProgressView()
+ .controlSize(.small)
+ .scaledScaleEffect(0.7)
+ case .completed:
+ Image(systemName: "checkmark")
+ .foregroundColor(.secondary)
+ case .error:
+ Image(systemName: "xmark")
+ .foregroundColor(.red.opacity(0.5))
+ case .cancelled:
+ Image(systemName: "slash.circle")
+ .foregroundColor(.gray.opacity(0.5))
+ case .waitForConfirmation:
+ EmptyView()
+ case .accepted:
+ EmptyView()
+ }
+ }
+ .scaledFont(size: chatFontSize - 1, weight: .medium)
+ }
+
+ @ViewBuilder
+ var progressTitleText: some View {
+ if tool.name == ServerToolName.findFiles.rawValue {
+ searchProgressView(
+ pattern: "Searched for files matching query: (.*)",
+ prefix: "Searched for files matching ",
+ singularSuffix: "match",
+ pluralSuffix: "matches"
+ )
+ } else if tool.name == ServerToolName.findTextInFiles.rawValue {
+ searchProgressView(
+ pattern: "Searched for text in files matching query: (.*)",
+ prefix: "Searched for text in files matching ",
+ singularSuffix: "result",
+ pluralSuffix: "results"
+ )
+ } else if tool.name == ServerToolName.readFile.rawValue || tool.name == CopilotToolName.readFile.rawValue {
+ readFileProgressView
+ } else if tool.name == ToolName.createFile.rawValue {
+ createFileProgressView
+ } else if tool.name == ServerToolName.replaceString.rawValue {
+ replaceStringProgressView
+ } else if tool.name == ToolName.insertEditIntoFile.rawValue {
+ insertEditIntoFileProgressView
+ } else if tool.name == ServerToolName.codebase.rawValue {
+ codebaseSearchProgressView
+ } else {
+ otherToolsProgressView
+ }
+ }
+
+ @ViewBuilder
+ func searchProgressView(pattern: String, prefix: String, singularSuffix: String, pluralSuffix: String) -> some View {
+ let message = tool.progressMessage ?? ""
+ let matchCountText: String = {
+ if let parsed = parsedFileListResult {
+ let suffix = parsed.count == 1 ? singularSuffix : pluralSuffix
+ return "\(parsed.count) \(suffix)"
+ }
+ return ""
+ }()
+
+ if let regex = try? NSRegularExpression(pattern: pattern),
+ let match = regex.firstMatch(in: message, range: NSRange(message.startIndex..., in: message)),
+ let range = Range(match.range(at: 1), in: message) {
+
+ let query = String(message[range])
+ let suffix = matchCountText.isEmpty ? "" : ": \(matchCountText)"
+
+ HStack(spacing: 0) {
+ Text(prefix)
+ Text(query)
+ .scaledFont(size: chatFontSize - 1, weight: .regular, design: .monospaced)
+ .padding(.horizontal, 4)
+ .padding(.vertical, 1)
+ .background(SecondarySystemFillColor)
+ .foregroundColor(.secondary)
+ .cornerRadius(4)
+ .padding(.horizontal, 2)
+ Text(suffix)
+ }
+ } else {
+ let displayMessage: String = {
+ if message.isEmpty {
+ return matchCountText
+ } else {
+ return message + (matchCountText.isEmpty ? "" : ": \(matchCountText)")
+ }
+ }()
+
+ markdownView(text: displayMessage)
+ }
+ }
+
+ @ViewBuilder
+ var readFileProgressView: some View {
+ let pattern = #"^Read file \[(?.+?)\]\((?.+?)\)(?:, lines (?\d+) to (?\d+))?"#
+ fileOperationProgressView(prefix: "Read", pattern: pattern) { match in
+ let message = tool.progressMessage ?? ""
+ if let startRange = Range(match.range(withName: "start"), in: message),
+ let endRange = Range(match.range(withName: "end"), in: message) {
+ let start = String(message[startRange])
+ let end = String(message[endRange])
+ Text(": \(start)-\(end)")
+ .foregroundColor(.secondary)
+ .scaledFont(size: chatFontSize - 1)
+ }
+ }
+ }
+
+ @ViewBuilder
+ var createFileProgressView: some View {
+ let pattern = #"^Created \[(?.+?)\]\((?.+?)\)"#
+ fileOperationProgressView(suffix: "created successfully.", pattern: pattern)
+ }
+ @ViewBuilder
+ var replaceStringProgressView: some View {
+ let pattern = #"^Edited \[(?.+?)\]\((?.+?)\) with replace_string_in_file tool"#
+ fileOperationProgressView(prefix: "Edited", suffix: "with replace_string_in_file tool.", pattern: pattern)
+ }
+
+ @ViewBuilder
+ var insertEditIntoFileProgressView: some View {
+ let pattern = #"^Edited \[(?.+?)\]\((?.+?)\) with insert_edit_into_file tool"#
+ fileOperationProgressView(prefix: "Edited", suffix: "with insert_edit_into_file tool.", pattern: pattern)
+ }
+
+ @ViewBuilder
+ var codebaseSearchProgressView: some View {
+ let pattern = #"^Searched (?.+) for "(?.+)", (?no|\d+) results?$"#
+ if let regex = try? NSRegularExpression(pattern: pattern),
+ let message = tool.progressMessage,
+ let match = regex.firstMatch(in: message, range: NSRange(message.startIndex..., in: message)),
+ let targetRange = Range(match.range(withName: "target"), in: message),
+ let queryRange = Range(match.range(withName: "query"), in: message),
+ let countRange = Range(match.range(withName: "count"), in: message) {
+
+ let target = String(message[targetRange])
+ let query = String(message[queryRange])
+ let countStr = String(message[countRange])
+ let count = countStr == "no" ? "0" : countStr
+ let suffix = count == "1" ? "result" : "results"
+
+ HStack(spacing: 0) {
+ Text("Searched \(target) for ")
+ Text(query)
+ .scaledFont(size: chatFontSize - 1, weight: .regular, design: .monospaced)
+ .padding(.horizontal, 4)
+ .padding(.vertical, 1)
+ .background(SecondarySystemFillColor)
+ .foregroundColor(.secondary)
+ .cornerRadius(4)
+ .padding(.horizontal, 2)
+ Text(": \(count) \(suffix)")
+ }
+ } else {
+ markdownView(text: tool.progressMessage ?? "")
+ }
+ }
+
+ @ViewBuilder
+ func fileOperationProgressView(
+ prefix: String? = nil,
+ suffix: String? = nil,
+ pattern: String,
+ @ViewBuilder extraContent: (NSTextCheckingResult) -> Content = { _ in EmptyView() }
+ ) -> some View {
+ let message = tool.progressMessage ?? ""
+
+ if tool.name == ToolName.createFile.rawValue, tool.status == .error {
+ if let input = tool.invokeParams?.input, let filePath = input["filePath"]?.value as? String {
+ let url = URL(fileURLWithPath: filePath)
+ let name = url.lastPathComponent
+ HStack(spacing: 4) {
+ drawFileIcon(url)
+ .scaledToFit()
+ .scaledFrame(width: 16, height: 16)
+ Text(name).scaledFont(size: chatFontSize - 1)
+ Text("File creation failed")
+ }
+ } else {
+ markdownView(text: message)
+ }
+ } else if let regex = try? NSRegularExpression(pattern: pattern),
+ let match = regex.firstMatch(in: message, range: NSRange(message.startIndex..., in: message)),
+ let nameRange = Range(match.range(withName: "name"), in: message),
+ let pathRange = Range(match.range(withName: "path"), in: message) {
+
+ let name = String(message[nameRange])
+ let pathString = String(message[pathRange])
+ let url = URL(string: pathString).flatMap { $0.scheme == "file" ? $0 : nil } ?? URL(fileURLWithPath: pathString)
+
+ HStack(spacing: 4) {
+ if let prefix {
+ Text(prefix)
+ }
+
+ drawFileIcon(url)
+ .scaledToFit()
+ .scaledFrame(width: 16, height: 16)
+
+ Button(action: {
+ NSWorkspace.shared.open(url)
+ }) {
+ Text(name)
+ .scaledFont(size: chatFontSize - 1)
+ .foregroundColor(isHoveringFileLink ? .primary : .secondary)
+ }
+ .buttonStyle(.plain)
+ .onHover { hovering in
+ isHoveringFileLink = hovering
+ if hovering {
+ NSCursor.pointingHand.push()
+ } else {
+ NSCursor.pop()
+ }
+ }
+
+ if let suffix {
+ Text(suffix)
+ }
+
+ extraContent(match)
+ .padding(.leading, -4)
+ }
+ } else {
+ markdownView(text: message)
+ }
+ }
+
+ @ViewBuilder
+ var otherToolsProgressView: some View {
+ let message: String = {
+ var msg = tool.progressMessage ?? ""
+ if tool.name == ToolName.createFile.rawValue {
+ if let input = tool.invokeParams?.input, let filePath = input["filePath"]?.value as? String {
+ let fileURL = URL(fileURLWithPath: filePath)
+ msg += ": [\(fileURL.lastPathComponent)](\(fileURL.absoluteString))"
+ }
+ }
+ return msg
+ }()
+
+ if message.isEmpty {
+ GenericToolTitleView(toolStatus: "Running", toolName: tool.name)
+ } else {
+ markdownView(text: message)
+ }
+ }
+
+ func markdownView(text: String) -> some View {
+ ThemedMarkdownText(
+ text: text,
+ context: .init(supportInsert: false),
+ foregroundColor: .secondary
+ )
+ .environment(\.openURL, OpenURLAction { url in
+ if url.scheme == "file" || url.isFileURL {
+ NSWorkspace.shared.open(url)
+ return .handled
+ } else {
+ return .systemAction
+ }
+ })
+ }
+
+ var progressErrorText: some View {
+ ThemedMarkdownText(
+ text: tool.error ?? "",
+ context: .init(supportInsert: false),
+ foregroundColor: .secondary
+ )
+ }
+
+ @ViewBuilder
+ func toolCallDetailSection(title: String, text: String) -> some View {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(title)
+ .scaledFont(size: chatFontSize - 1, weight: .medium)
+ .foregroundColor(.secondary)
+ markdownView(text: text)
+ .toolCallDetailStyle(fontScale: fontScale)
+ }
+ }
+
+ var mcpDetailView: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ if let inputMessage = tool.inputMessage, !inputMessage.isEmpty {
+ toolCallDetailSection(title: "Input", text: inputMessage)
+ }
+ if let errorMessage = tool.error, !errorMessage.isEmpty {
+ toolCallDetailSection(title: "Output", text: errorMessage)
+ }
+ if let result = tool.result, !result.isEmpty {
+ toolCallDetailSection(title: "Output", text: toolResultText ?? "")
+ }
+ }
+ }
+
+ var progress: some View {
+ HStack(spacing: 4) {
+ statusIcon
+ .scaledFrame(width: 16, height: 16)
+
+ progressTitleText
+ .scaledFont(size: chatFontSize - 1)
+ .lineLimit(1)
+
+ Spacer()
+ }
+ .help(tool.progressMessage ?? "")
+ }
+
+ var toolResultText: String? {
+ tool.result?.compactMap({ item -> String? in
+ if case .text(let s) = item { return s }
+ return nil
+ }).joined(separator: "\n")
+ }
+
+ func extractCreateFileContent(from text: String) -> String {
+ let pattern = #"(?s)\n?(.*?)\n?"#
+ if let regex = try? NSRegularExpression(pattern: pattern),
+ let match = regex.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)),
+ let range = Range(match.range(at: 1), in: text) {
+ return String(text[range])
+ }
+ return text
+ }
+
+ func extractInsertEditContent(from text: String) -> String {
+ let pattern = #"(?s)\n?(.*?)\n?"#
+ if let regex = try? NSRegularExpression(pattern: pattern),
+ let match = regex.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)),
+ let range = Range(match.range(at: 1), in: text) {
+ return String(text[range])
+ }
+ return text
+ }
+
+ var parsedFileListResult: (count: Int, files: [FileSearchResult])? {
+ guard let resultText = toolResultText,
+ !resultText.isEmpty else {
+ return nil
+ }
+
+ // Parse find_files result
+ if tool.name == ServerToolName.findFiles.rawValue {
+ if resultText.hasPrefix("No files found") {
+ return (0, [])
+ }
+
+ let pattern = "Found (\\d+) files? matching query:"
+ if let regex = try? NSRegularExpression(pattern: pattern),
+ let match = regex.firstMatch(in: resultText, range: NSRange(resultText.startIndex..., in: resultText)),
+ let range = Range(match.range(at: 1), in: resultText),
+ let count = Int(resultText[range]) {
+
+ if let newlineIndex = resultText.firstIndex(of: "\n") {
+ let filesPart = resultText[resultText.index(after: newlineIndex)...]
+ let files = filesPart.split(separator: "\n").map { FileSearchResult(file: String($0)) }
+ return (count, files)
+ }
+ }
+ }
+
+ // Parse grep_search result
+ if tool.name == ServerToolName.findTextInFiles.rawValue {
+ if resultText.contains("no results") {
+ return (0, [])
+ }
+
+ let countPattern = "Searched text for: .*, (\\d+) results?"
+ var count = 0
+ if let regex = try? NSRegularExpression(pattern: countPattern),
+ let match = regex.firstMatch(in: resultText, range: NSRange(resultText.startIndex..., in: resultText)),
+ let range = Range(match.range(at: 1), in: resultText),
+ let parsedCount = Int(resultText[range]) {
+ count = parsedCount
+ }
+
+ var files: [FileSearchResult] = []
+ let lines = resultText.split(separator: "\n")
+ // Skip the first line which is the summary
+ if lines.count > 1 {
+ for line in lines.dropFirst() {
+ let parts = line.split(separator: ":", maxSplits: 2)
+ if parts.count >= 2 {
+ let path = String(parts[0])
+ if let lineNumber = Int(parts[1]) {
+ let content = parts.count > 2 ? String(parts[2]) : nil
+ files.append(FileSearchResult(file: path, startLine: lineNumber, content: content))
+ } else {
+ files.append(FileSearchResult(file: path))
+ }
+ }
+ }
+ }
+
+ return (count, files)
+ }
+
+ // Parse list_dir result
+ if tool.name == ServerToolName.listDir.rawValue {
+ let files = resultText.split(separator: "\n").map { FileSearchResult(file: String($0)) }
+ return (files.count, files)
+ }
+
+ return nil
+ }
+
+ var parsedCodebaseSearchResult: (count: Int, files: [FileSearchResult])? {
+ guard let details = tool.resultDetails, !details.isEmpty else { return nil }
+
+ var files: [FileSearchResult] = []
+ for item in details {
+ if case .fileLocation(let location) = item {
+ files
+ .append(
+ FileSearchResult(
+ file: location.uri,
+ startLine: location.range.start.line,
+ endLine: location.range.end.line
+ )
+ )
+ }
+ }
+
+ return (files.count, files)
+ }
+
+ var body: some View {
+ WithPerceptionTracking {
+ if tool.name == ToolName.createFile.rawValue, let resultText = toolResultText, !resultText.isEmpty {
+ ToolStatusDetailsView(
+ title: progress,
+ content: markdownView(text: extractCreateFileContent(from: resultText))
+ )
+ } else if tool.name == ServerToolName.replaceString.rawValue, let resultText = toolResultText, !resultText.isEmpty {
+ ToolStatusDetailsView(
+ title: progress,
+ content: markdownView(text: resultText)
+ )
+ } else if tool.name == ToolName.insertEditIntoFile.rawValue, let resultText = toolResultText, !resultText.isEmpty {
+ ToolStatusDetailsView(
+ title: progress,
+ content: markdownView(text: extractInsertEditContent(from: resultText))
+ )
+ } else if tool.toolType == .mcp {
+ ToolStatusDetailsView(
+ title: progress,
+ content: mcpDetailView
+ )
+ } else if tool.status == .error {
+ ToolStatusDetailsView(
+ title: progress,
+ content: progressErrorText
+ )
+ } else if let result = parsedFileListResult,
+ !result.files.isEmpty {
+ ExpandableFileListView(
+ progressMessage: progressTitleText,
+ files: result.files,
+ chatFontSize: chatFontSize,
+ helpText: tool.progressMessage ?? ""
+ )
+ .scaledPadding(.horizontal, 6)
+ } else if let result = parsedCodebaseSearchResult,
+ !result.files.isEmpty {
+ ExpandableFileListView(
+ progressMessage: progressTitleText,
+ files: result.files,
+ chatFontSize: chatFontSize,
+ helpText: tool.progressMessage ?? ""
+ )
+ .scaledPadding(.horizontal, 6)
+ } else {
+ progress.scaledPadding(.horizontal, 6)
+ }
+ }
+ }
+}
+
+
+private struct ToolStatusDetailsView: View {
+ var title: Title
+ var content: Content
+
+ @State private var isExpanded = false
+ @AppStorage(\.fontScale) var fontScale
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 2) {
+
+ Button(action: {
+ isExpanded.toggle()
+ }) {
+ HStack(spacing: 2) {
+ title
+
+ 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))
+ }
+ .buttonStyle(.plain)
+ .scaledPadding(.horizontal, 6)
+ .toolStatusStyle(withBackground: !isExpanded, fontScale: fontScale)
+
+ if isExpanded {
+ content
+ .scaledPadding(.horizontal, 8)
+ }
+ }
+ .toolStatusStyle(withBackground: isExpanded, fontScale: fontScale)
+ }
+}
+
+private extension View {
+ func toolStatusStyle(withBackground: Bool, fontScale: CGFloat) -> some View {
+ /// Leverage the `modify` extension to avoid refreshing of chat panel `List` view
+ self.modify { view in
+ if withBackground {
+ view
+ .scaledPadding(.vertical, 6)
+ } else {
+ view
+ }
+ }
+ }
+
+ func toolCallDetailStyle(fontScale: CGFloat) -> some View {
+ /// Leverage the `modify` extension to avoid refreshing of chat panel `List` view
+ self.modify { view in
+ view
+ .foregroundColor(.secondary)
+ .scaledPadding(4)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(SecondarySystemFillColor)
+ .clipShape(RoundedRectangle(cornerRadius: 6))
+ .background(
+ RoundedRectangle(cornerRadius: 6)
+ .stroke(Color.agentToolStatusOutlineColor, lineWidth: 1 * fontScale)
+ )
+ }
+ }
+}
+
+// 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/ConversationProgressStepView.swift b/Core/Sources/ConversationTab/Views/ConversationProgressStepView.swift
index 7697e422..739b126a 100644
--- a/Core/Sources/ConversationTab/Views/ConversationProgressStepView.swift
+++ b/Core/Sources/ConversationTab/Views/ConversationProgressStepView.swift
@@ -46,12 +46,11 @@ struct StatusItemView: View {
.scaledFont(size: chatFontSize - 1, weight: .medium)
}
- var statusTitle: some View {
- var title = step.title
+ var statusTitleText: String {
if step.id == ProjectContextSkill.ProgressID && step.status == .failed {
- title = step.error?.message ?? step.title
+ return step.error?.message ?? step.title
}
- return Text(title)
+ return step.title
}
var body: some View {
@@ -60,12 +59,13 @@ struct StatusItemView: View {
statusIcon
.scaledFrame(width: 16, height: 16)
- statusTitle
+ Text(statusTitleText)
.scaledFont(size: chatFontSize - 1)
.lineLimit(1)
Spacer()
}
+ .help(statusTitleText)
}
}
}
diff --git a/Core/Sources/ConversationTab/Views/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 3762c1a1..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,26 +22,39 @@ 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
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .top, spacing: 6) {
Image(systemName: style.iconName)
- .font(Font.system(size: 12))
.foregroundColor(style.color)
VStack(alignment: .leading, spacing: 8) {
content()
}
+ .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 f00d76e4..f9a1409b 100644
--- a/Core/Sources/ConversationTab/Views/ThemedMarkdownText.swift
+++ b/Core/Sources/ConversationTab/Views/ThemedMarkdownText.swift
@@ -26,8 +26,12 @@ public struct ThemedMarkdownText: View {
@AppStorage(\.chatFontSize) var chatFontSize
@Environment(\.colorScheme) var colorScheme
+ static let defaultForegroundColor: Color = .primary
+
@StateObject private var fontScaleManager = FontScaleManager.shared
+ let foregroundColor: Color
+
var fontScale: Double {
fontScaleManager.currentScale
}
@@ -43,9 +47,10 @@ public struct ThemedMarkdownText: View {
let text: String
let context: MarkdownActionProvider
- public init(text: String, context: MarkdownActionProvider) {
+ public init(text: String, context: MarkdownActionProvider, foregroundColor: Color? = nil) {
self.text = text
self.context = context
+ self.foregroundColor = foregroundColor ?? Self.defaultForegroundColor
}
init(text: String, chat: StoreOf) {
@@ -54,6 +59,7 @@ public struct ThemedMarkdownText: View {
self.context = .init(onInsert: { content in
chat.send(.insertCode(content))
})
+ self.foregroundColor = Self.defaultForegroundColor
}
public var body: some View {
@@ -61,6 +67,7 @@ public struct ThemedMarkdownText: View {
.textSelection(.enabled)
.markdownTheme(.custom(
fontSize: scaledChatFontSize,
+ foregroundColor: foregroundColor,
codeFont: scaledChatCodeFont,
codeBlockBackgroundColor: {
if syncCodeHighlightTheme {
@@ -95,13 +102,14 @@ public struct ThemedMarkdownText: View {
extension MarkdownUI.Theme {
static func custom(
fontSize: Double,
+ foregroundColor: Color,
codeFont: NSFont,
codeBlockBackgroundColor: Color,
codeBlockLabelColor: Color,
context: MarkdownActionProvider
) -> MarkdownUI.Theme {
.gitHub.text {
- ForegroundColor(.primary)
+ ForegroundColor(foregroundColor)
BackgroundColor(Color.clear)
FontSize(fontSize)
}
@@ -166,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 7cb5eb73..f572454b 100644
--- a/Core/Sources/ConversationTab/Views/WorkingSetView.swift
+++ b/Core/Sources/ConversationTab/Views/WorkingSetView.swift
@@ -14,23 +14,26 @@ struct WorkingSetView: View {
private let r: Double = 8
+ @State private var isExpanded: Bool = false
+
var body: some View {
WithPerceptionTracking {
VStack(spacing: 4) {
- WorkingSetHeader(chat: chat)
- .scaledFrame(height: 24)
+ WorkingSetHeader(chat: chat, isExpanded: $isExpanded)
+ .scaledPadding(.vertical, 2)
.scaledPadding(.leading, 7)
- VStack(spacing: 0) {
- ForEach(chat.fileEditMap.elements, id: \.key.path) { element in
- FileEditView(chat: chat, fileEdit: element.value)
+ if isExpanded {
+ VStack(spacing: 0) {
+ ForEach(chat.fileEditMap.elements, id: \.key.path) { element in
+ FileEditView(chat: chat, fileEdit: element.value)
+ }
}
}
}
.scaledPadding(.horizontal, 5)
- .scaledPadding(.top, 8)
- .scaledPadding(.bottom, 10)
+ .scaledPadding(.vertical, 4)
.frame(maxWidth: .infinity)
.background(
RoundedCorners(tl: r, tr: r, bl: 0, br: 0)
@@ -46,6 +49,7 @@ struct WorkingSetView: View {
struct WorkingSetHeader: View {
let chat: StoreOf
+ @Binding var isExpanded: Bool
@Environment(\.colorScheme) var colorScheme
@@ -58,38 +62,47 @@ struct WorkingSetHeader: View {
text: String,
textForegroundColor: Color = .white,
textBackgroundColor: Color = .gray,
+ buttonStyle: some PrimitiveButtonStyle = .bordered,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
Text(text)
- .scaledFont(.body)
- .foregroundColor(textForegroundColor)
- .scaledPadding(.horizontal, 6)
- .padding(.vertical, 2)
- .background(textBackgroundColor)
- .cornerRadius(2)
- .overlay(
- RoundedRectangle(cornerRadius: 2)
- .stroke(Color.white.opacity(0.07), lineWidth: 1)
- )
- .scaledFrame(width: 60, height: 15, alignment: .center)
+ .scaledFont(size: 11)
}
- .buttonStyle(PlainButtonStyle())
+ .buttonStyle(buttonStyle)
}
var body: some View {
WithPerceptionTracking {
HStack(spacing: 0) {
- Text(getTitle())
- .foregroundColor(.secondary)
- .scaledFont(size: 13)
-
- Spacer()
+ HStack(spacing: 2) {
+ Image(systemName: isExpanded ? "chevron.down" : "chevron.right")
+ .resizable()
+ .scaledToFit()
+ .padding(3)
+ .scaledFrame(width: 16, height: 16)
+ .foregroundColor(.secondary)
+
+ Text(getTitle())
+ .foregroundColor(.secondary)
+ .scaledFont(size: 13)
+
+ Spacer()
+ }
+ .frame(maxWidth: .infinity)
+ .overlay(
+ Color.clear
+ .contentShape(Rectangle())
+ .onTapGesture {
+ isExpanded.toggle()
+ }
+ .allowsHitTesting(true)
+ )
if chat.fileEditMap.contains(where: {_, fileEdit in
return fileEdit.status == .none
}) {
- HStack(spacing: -10) {
+ HStack(spacing: 6) {
/// Undo all edits
buildActionButton(
text: "Undo",
@@ -101,7 +114,11 @@ struct WorkingSetHeader: View {
.help("Undo All Edits")
/// Keep all edits
- buildActionButton(text: "Keep", textBackgroundColor: Color("WorkingSetHeaderKeepButtonColor")) {
+ buildActionButton(
+ text: "Keep",
+ textBackgroundColor: Color("WorkingSetHeaderKeepButtonColor"),
+ buttonStyle: .borderedProminent
+ ) {
chat.send(.keepEdits(fileURLs: chat.fileEditMap.values.map { $0.fileURL }))
}
.help("Keep All Edits")
@@ -189,9 +206,8 @@ struct FileEditView: View {
var body: some View {
HStack(spacing: 0) {
- HStack(spacing: 4) {
+ HStack(alignment: .center, spacing: 4) {
drawFileIcon(fileEdit.fileURL)
- .resizable()
.scaledToFit()
.scaledFrame(width: 16, height: 16)
.foregroundColor(.secondary)
diff --git a/Core/Sources/GitHubCopilotViewModel/GitHubCopilotViewModel.swift b/Core/Sources/GitHubCopilotViewModel/GitHubCopilotViewModel.swift
index bca4079f..52062f58 100644
--- a/Core/Sources/GitHubCopilotViewModel/GitHubCopilotViewModel.swift
+++ b/Core/Sources/GitHubCopilotViewModel/GitHubCopilotViewModel.swift
@@ -4,6 +4,8 @@ import ComposableArchitecture
import Status
import SwiftUI
import Cache
+import Client
+import Logger
public struct SignInResponse {
public let status: SignInInitiateStatus
@@ -29,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() {}
@@ -126,7 +131,7 @@ public class GitHubCopilotViewModel: ObservableObject {
waitingForSignIn = false
}
- public func copyAndOpen() {
+ public func copyAndOpen(fromHostApp: Bool = false) {
waitingForSignIn = true
guard let signInResponse else {
toast("Missing sign in details.", .error)
@@ -137,10 +142,10 @@ public class GitHubCopilotViewModel: ObservableObject {
pasteboard.setString(signInResponse.userCode, forType: NSPasteboard.PasteboardType.string)
toast("Sign-in code \(signInResponse.userCode) copied", .info)
NSWorkspace.shared.open(signInResponse.verificationURL)
- waitForSignIn()
+ waitForSignIn(fromHostApp: fromHostApp)
}
- public func waitForSignIn() {
+ public func waitForSignIn(fromHostApp: Bool = false) {
Task {
do {
guard waitingForSignIn else { return }
@@ -155,14 +160,19 @@ public class GitHubCopilotViewModel: ObservableObject {
self.status = status
await Status.shared.updateAuthStatus(.loggedIn, username: username)
broadcastStatusChange()
- let models = try? await service.models()
- if let models = models, !models.isEmpty {
- CopilotModelManager.updateLLMs(models)
+ if !fromHostApp {
+ let models = try? await service.models()
+ if let models = models, !models.isEmpty {
+ CopilotModelManager.updateLLMs(models)
+ }
+ } else {
+ let xpcService = try getService()
+ _ = try? await xpcService.updateCopilotModels()
}
} catch let error as GitHubCopilotError {
switch error {
case .languageServerError(.timeout):
- waitForSignIn()
+ waitForSignIn(fromHostApp: fromHostApp)
return
case .languageServerError(
.serverError(
@@ -357,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 569b0a56..a8910979 100644
--- a/Core/Sources/HostApp/AdvancedSettings/ChatSection.swift
+++ b/Core/Sources/HostApp/AdvancedSettings/ChatSection.swift
@@ -1,15 +1,20 @@
+import AppKitExtension
import Client
import ComposableArchitecture
+import ConversationServiceProvider
import SwiftUI
import Toast
import XcodeInspector
import SharedUIComponents
import Logger
+import SystemUtils
struct ChatSection: View {
@AppStorage(\.autoAttachChatToXcode) var autoAttachChatToXcode
@AppStorage(\.enableFixError) var enableFixError
- @State private var isEditorPreviewEnabled: Bool = false
+ @AppStorage(\.enableSubagent) var enableSubagent
+ @ObservedObject private var featureFlags = FeatureFlagManager.shared
+ @ObservedObject private var copilotPolicy = CopilotPolicyManager.shared
var body: some View {
SettingsSection(title: "Chat Settings") {
@@ -25,12 +30,37 @@ struct ChatSection: View {
Divider()
- if isEditorPreviewEnabled {
+ if featureFlags.isEditorPreviewEnabled {
// Custom Prompts - .github/prompts/*.prompt.md
PromptFileSetting(promptType: .prompt)
.padding(SettingsToggle.defaultPadding)
Divider()
+
+ }
+
+ if featureFlags.isAgentModeEnabled && copilotPolicy.isCustomAgentEnabled {
+ // Custom Agents - .github/agents/*.agent.md
+ AgentFileSetting(promptType: .agent)
+ .padding(SettingsToggle.defaultPadding)
+
+ Divider()
+
+ // SubAgent toggle
+ SettingsToggle(
+ title: "Enable Subagent",
+ subtitle: "Allows Copilot Agent mode to call custom agents as subagent. Requires GitHub Copilot for Xcode restart to take effect.",
+ isOn: Binding(
+ get: { enableSubagent && copilotPolicy.isSubagentEnabled },
+ set: { if copilotPolicy.isSubagentEnabled { enableSubagent = $0 } }
+ ),
+ badge: copilotPolicy.isSubagentEnabled
+ ? nil
+ : .disabledByPolicy(feature: "Subagents", isPlural: true)
+ )
+ .disabled(!copilotPolicy.isSubagentEnabled)
+
+ Divider()
}
// Auto Attach toggle
@@ -58,28 +88,19 @@ struct ChatSection: View {
// Font Size
FontSizeSetting()
.padding(SettingsToggle.defaultPadding)
- }
- .onAppear {
- Task {
- await updateEditorPreviewFeatureFlag()
- }
- }
- .onReceive(DistributedNotificationCenter.default()
- .publisher(for: .gitHubCopilotFeatureFlagsDidChange)) { _ in
- Task {
- await updateEditorPreviewFeatureFlag()
- }
- }
- }
-
- private func updateEditorPreviewFeatureFlag() async {
- do {
- let service = try getService()
- if let featureFlags = try await service.getCopilotFeatureFlags() {
- isEditorPreviewEnabled = featureFlags.editorPreviewFeatures
+
+ Divider()
+
+ if featureFlags.isAgentModeEnabled {
+ // Agent Max Tool Calling Requests
+ AgentMaxToolCallLoopSetting()
+ .padding(SettingsToggle.defaultPadding)
+
+ Divider()
}
- } catch {
- Logger.client.error("Failed to get copilot feature flags: \(error)")
+
+ // Auto Compress
+ AgentAutoCompressSetting()
}
}
}
@@ -260,6 +281,87 @@ struct FontSizeSetting: View {
}
}
+struct AgentMaxToolCallLoopSetting: View {
+ @AppStorage(\.agentMaxToolCallingLoop) var agentMaxToolCallingLoop
+ @State private var numberInput: String = ""
+ @State private var debounceTimer: Timer?
+
+ private static let debounceDelay: TimeInterval = 0.5
+
+ var body: some View {
+ WithPerceptionTracking {
+ HStack {
+ VStack(alignment: .leading) {
+ Text("Agent Max Requests")
+ .font(.body)
+ Text("Sets the maximum number of tool call requests Copilot can make in a single agent turn.")
+ .font(.footnote)
+ }
+
+ Spacer()
+
+ TextField("", text: $numberInput)
+ .textFieldStyle(.roundedBorder)
+ .frame(minWidth: 40, maxWidth: 120)
+ .fixedSize(horizontal: true, vertical: false)
+ .onChange(of: numberInput) { newValue in
+ if newValue.isEmpty { return }
+
+ guard let number = Int(newValue.filter { $0.isNumber }), number > 0 else {
+ numberInput = ""
+ return
+ }
+
+ numberInput = "\(number)"
+
+ debounceTimer?.invalidate()
+ debounceTimer = Timer.scheduledTimer(
+ withTimeInterval: Self.debounceDelay,
+ repeats: false
+ ) { _ in
+ agentMaxToolCallingLoop = number
+ DistributedNotificationCenter
+ .default()
+ .post(name: .githubCopilotAgentMaxToolCallingLoopDidChange, object: nil)
+ }
+ }
+ }
+ .onAppear {
+ numberInput = "\(agentMaxToolCallingLoop)"
+ }
+ .onDisappear {
+ // Flush before invalidating
+ if let timer = debounceTimer, timer.isValid {
+ timer.fire()
+ }
+
+ debounceTimer?.invalidate()
+ debounceTimer = nil
+ }
+ }
+ }
+}
+
+struct AgentAutoCompressSetting: View {
+ @AppStorage(\.autoCompress) var autoCompress
+
+ var body: some View {
+ SettingsToggle(
+ title: "Auto Compress",
+ subtitle: "Automatically compact the conversation history to save contect tokens.",
+ isOn: Binding(
+ get: { autoCompress },
+ set: {
+ autoCompress = $0
+ DistributedNotificationCenter
+ .default()
+ .post(name: .githubCopilotAgentAutoCompressDidChange, object: nil)
+ }
+ )
+ )
+ }
+}
+
struct CopilotInstructionSetting: View {
@State var isGlobalInstructionsViewOpen = false
@Environment(\.toast) var toast
@@ -351,8 +453,15 @@ struct PromptFileSetting: View {
}
.sheet(isPresented: $isCreateSheetPresented) {
CreateCustomCopilotFileView(
- isOpen: $isCreateSheetPresented,
- promptType: promptType
+ promptType: promptType,
+ editorPluginVersion: SystemUtils.editorPluginVersionString,
+ getCurrentProjectURL: { await getCurrentProjectURL() },
+ onSuccess: { message in
+ toast(message, .info)
+ },
+ onError: { message in
+ toast(message, .error)
+ }
)
}
}
@@ -377,6 +486,105 @@ struct PromptFileSetting: View {
}
}
+struct AgentFileSetting: View {
+ let promptType: PromptType
+ @State private var isCreateSheetPresented = false
+ @Environment(\.toast) var toast
+
+ var body: some View {
+ WithPerceptionTracking {
+ HStack {
+ VStack(alignment: .leading) {
+ Text(promptType.settingTitle)
+ .font(.body)
+ Text(
+ (try? AttributedString(markdown: promptType.description)) ?? AttributedString(
+ promptType.description
+ )
+ )
+ .font(.footnote)
+ }
+
+ Spacer()
+
+ Button("Create") {
+ isCreateSheetPresented = true
+ }
+
+ Button("Browse \(promptType.displayName)s") {
+ openDirectory()
+ }
+ }
+ .sheet(isPresented: $isCreateSheetPresented) {
+ CreateCustomCopilotFileView(
+ promptType: promptType,
+ editorPluginVersion: SystemUtils.editorPluginVersionString,
+ getCurrentProjectURL: { await getCurrentProjectURL() },
+ onSuccess: { message in
+ toast(message, .info)
+ },
+ onError: { message in
+ toast(message, .error)
+ }
+ )
+ }
+ }
+ }
+
+ private func openDirectory() {
+ Task {
+ guard let projectURL = await getCurrentProjectURL() else {
+ toast("No active workspace found", .error)
+ return
+ }
+
+ let directory = promptType.getDirectoryPath(projectURL: projectURL)
+
+ do {
+ try ensureDirectoryExists(at: directory)
+
+ // Open file picker for .agent.md files
+ await MainActor.run {
+ let panel = NSOpenPanel()
+ panel.allowedContentTypes = [.init(filenameExtension: "agent.md") ?? .plainText]
+ panel.allowsMultipleSelection = false
+ panel.canChooseFiles = true
+ panel.canChooseDirectories = false
+ panel.level = .modalPanel
+ panel.directoryURL = directory
+ panel.message = "Select an existing agent file"
+ panel.prompt = "Select"
+ panel.showsHiddenFiles = false
+
+ panel.allowsOtherFileTypes = false
+ panel.isExtensionHidden = false
+
+ panel.begin { response in
+ if response == .OK, let selectedURL = panel.url {
+ // If the file doesn't exist, create it
+ if !FileManager.default.fileExists(atPath: selectedURL.path) {
+ do {
+ // Create empty agent file with basic structure
+ let template = promptType.defaultTemplate
+ try template.write(to: selectedURL, atomically: true, encoding: .utf8)
+ } catch {
+ toast("Failed to create agent file: \(error)", .error)
+ return
+ }
+ }
+
+ // Open the file in Xcode
+ NSWorkspace.openFileInXcode(fileURL: selectedURL)
+ }
+ }
+ }
+ } catch {
+ toast("Failed to create \(promptType.directoryName) directory: \(error)", .error)
+ }
+ }
+ }
+}
+
#Preview {
ChatSection()
.frame(width: 600)
diff --git a/Core/Sources/HostApp/AdvancedSettings/CustomCopilotHelper.swift b/Core/Sources/HostApp/AdvancedSettings/CustomCopilotHelper.swift
index 072dd21f..d93ae8d9 100644
--- a/Core/Sources/HostApp/AdvancedSettings/CustomCopilotHelper.swift
+++ b/Core/Sources/HostApp/AdvancedSettings/CustomCopilotHelper.swift
@@ -5,123 +5,31 @@ import SwiftUI
import Toast
import XcodeInspector
import SystemUtils
+import SharedUIComponents
+import Workspace
+import LanguageServerProtocol
-public enum PromptType: String, CaseIterable, Equatable {
- case instructions = "instructions"
- case prompt = "prompt"
-
- /// The directory name under .github where files of this type are stored
- var directoryName: String {
- switch self {
- case .instructions:
- return "instructions"
- case .prompt:
- return "prompts"
- }
- }
-
- /// The file extension for this prompt type
- var fileExtension: String {
- switch self {
- case .instructions:
- return ".instructions.md"
- case .prompt:
- return ".prompt.md"
- }
- }
-
- /// Human-readable name for display purposes
- var displayName: String {
- switch self {
- case .instructions:
- return "Instruction File"
- case .prompt:
- return "Prompt File"
- }
- }
-
- /// Human-readable name for settings
- var settingTitle: String {
- switch self {
- case .instructions:
- return "Custom Instructions"
- case .prompt:
- return "Prompt Files"
- }
- }
-
- /// Description for the prompt type
- var description: String {
- switch self {
- case .instructions:
- return "Configure `.github/instructions/*.instructions.md` files scoped to specific file patterns or tasks."
- case .prompt:
- return "Configure `.github/prompts/*.prompt.md` files for reusable prompts. Trigger with '/' commands in the Chat view."
- }
- }
-
- /// Default template content for new files
- var defaultTemplate: String {
- switch self {
- case .instructions:
- return """
- ---
- applyTo: '**'
- ---
- Provide project context and coding guidelines that AI should follow when generating code, or answering questions.
-
- """
- case .prompt:
- return """
- ---
- description: Prompt Description
- ---
- Define the task to achieve, including specific requirements, constraints, and success criteria.
+// MARK: - Workspace URL Helpers
- """
- }
- }
-
- var helpLink: String {
- var editorPluginVersion = SystemUtils.editorPluginVersionString
- if editorPluginVersion == "0.0.0" {
- editorPluginVersion = "main"
- }
-
- switch self {
- case .instructions:
- return "https://github.com/github/CopilotForXcode/blob/\(editorPluginVersion)/Docs/CustomInstructions.md"
- case .prompt:
- return "https://github.com/github/CopilotForXcode/blob/\(editorPluginVersion)/Docs/PromptFiles.md"
- }
- }
-
- /// Get the full file path for a given name and project URL
- func getFilePath(fileName: String, projectURL: URL) -> URL {
- let directory = getDirectoryPath(projectURL: projectURL)
- return directory.appendingPathComponent("\(fileName)\(fileExtension)")
- }
-
- /// Get the directory path for this prompt type
- func getDirectoryPath(projectURL: URL) -> URL {
- return projectURL.appendingPathComponent(".github/\(directoryName)")
+private func getCurrentWorkspaceURL() async -> URL? {
+ guard let service = try? getService(),
+ let inspectorData = try? await service.getXcodeInspectorData() else {
+ return nil
}
-}
-
-func getCurrentProjectURL() async -> URL? {
- let service = try? getService()
- let inspectorData = try? await service?.getXcodeInspectorData()
- var currentWorkspace: URL?
- if let url = inspectorData?.realtimeActiveWorkspaceURL,
+ if let url = inspectorData.realtimeActiveWorkspaceURL,
let workspaceURL = URL(string: url),
workspaceURL.path != "/" {
- currentWorkspace = workspaceURL
- } else if let url = inspectorData?.latestNonRootWorkspaceURL {
- currentWorkspace = URL(string: url)
+ return workspaceURL
+ } else if let url = inspectorData.latestNonRootWorkspaceURL {
+ return URL(string: url)
}
- guard let workspaceURL = currentWorkspace,
+ return nil
+}
+
+func getCurrentProjectURL() async -> URL? {
+ guard let workspaceURL = await getCurrentWorkspaceURL(),
let projectURL = WorkspaceXcodeWindowInspector.extractProjectURL(
workspaceURL: workspaceURL,
documentURL: nil
@@ -132,6 +40,22 @@ func getCurrentProjectURL() async -> URL? {
return projectURL
}
+// MARK: - Workspace Folders
+
+func getWorkspaceFolders() async -> [WorkspaceFolder]? {
+ guard let workspaceURL = await getCurrentWorkspaceURL(),
+ let workspaceInfo = WorkspaceFile.getWorkspaceInfo(workspaceURL: workspaceURL) else {
+ return nil
+ }
+
+ let projects = WorkspaceFile.getProjects(workspace: workspaceInfo)
+ return projects.map { project in
+ WorkspaceFolder(uri: project.uri, name: project.name)
+ }
+}
+
+// MARK: - File System Helpers
+
func ensureDirectoryExists(at url: URL) throws {
let fileManager = FileManager.default
if !fileManager.fileExists(atPath: url.path) {
diff --git a/Core/Sources/HostApp/AdvancedSettings/DisabledLanguageList.swift b/Core/Sources/HostApp/AdvancedSettings/DisabledLanguageList.swift
index d869b9ca..2ccbdd71 100644
--- a/Core/Sources/HostApp/AdvancedSettings/DisabledLanguageList.swift
+++ b/Core/Sources/HostApp/AdvancedSettings/DisabledLanguageList.swift
@@ -5,13 +5,8 @@ import SharedUIComponents
extension List {
@ViewBuilder
func removeBackground() -> some View {
- if #available(macOS 13.0, *) {
- scrollContentBackground(.hidden)
- .listRowBackground(EmptyView())
- } else {
- background(Color.clear)
- .listRowBackground(EmptyView())
- }
+ scrollContentBackground(.hidden)
+ .listRowBackground(EmptyView())
}
}
@@ -80,11 +75,7 @@ struct DisabledLanguageList: View {
}
}
.modify { view in
- if #available(macOS 13.0, *) {
- view.listRowSeparator(.hidden).listSectionSeparator(.hidden)
- } else {
- view
- }
+ view.listRowSeparator(.hidden).listSectionSeparator(.hidden)
}
}
.removeBackground()
diff --git a/Core/Sources/HostApp/AdvancedSettings/SuggestionSection.swift b/Core/Sources/HostApp/AdvancedSettings/SuggestionSection.swift
index cb86bde3..689ccaa5 100644
--- a/Core/Sources/HostApp/AdvancedSettings/SuggestionSection.swift
+++ b/Core/Sources/HostApp/AdvancedSettings/SuggestionSection.swift
@@ -4,8 +4,10 @@ struct SuggestionSection: View {
@AppStorage(\.realtimeSuggestionToggle) var realtimeSuggestionToggle
@AppStorage(\.suggestionFeatureEnabledProjectList) var suggestionFeatureEnabledProjectList
@AppStorage(\.acceptSuggestionWithTab) var acceptSuggestionWithTab
+ @AppStorage(\.realtimeNESToggle) var realtimeNESToggle
@State var isSuggestionFeatureDisabledLanguageListViewOpen = false
@State private var shouldPresentTurnoffSheet = false
+ @ObservedObject private var featureFlags = FeatureFlagManager.shared
var realtimeSuggestionBinding : Binding {
Binding(
@@ -23,9 +25,18 @@ struct SuggestionSection: View {
var body: some View {
SettingsSection(title: "Suggestion Settings") {
SettingsToggle(
- title: "Request suggestions while typing",
+ title: "Enable completions while typing",
isOn: realtimeSuggestionBinding
)
+
+ if featureFlags.isEditorPreviewEnabled {
+ Divider()
+ SettingsToggle(
+ title: "Enable Next Edit Suggestions (NES)",
+ isOn: $realtimeNESToggle
+ )
+ }
+
Divider()
SettingsToggle(
title: "Accept suggestions with Tab",
diff --git a/Core/Sources/HostApp/BYOKSettings/ApiKeySheet.swift b/Core/Sources/HostApp/BYOKSettings/ApiKeySheet.swift
index e3ce7ba9..4f93eee0 100644
--- a/Core/Sources/HostApp/BYOKSettings/ApiKeySheet.swift
+++ b/Core/Sources/HostApp/BYOKSettings/ApiKeySheet.swift
@@ -1,5 +1,6 @@
import GitHubCopilotService
import SwiftUI
+import SharedUIComponents
struct ApiKeySheet: View {
@ObservedObject var dataManager: BYOKModelManagerObservable
diff --git a/Core/Sources/HostApp/BYOKSettings/ModelSheet.swift b/Core/Sources/HostApp/BYOKSettings/ModelSheet.swift
index 4474dff4..4ce44c91 100644
--- a/Core/Sources/HostApp/BYOKSettings/ModelSheet.swift
+++ b/Core/Sources/HostApp/BYOKSettings/ModelSheet.swift
@@ -1,5 +1,6 @@
import GitHubCopilotService
import SwiftUI
+import SharedUIComponents
struct ModelSheet: View {
@ObservedObject var dataManager: BYOKModelManagerObservable
diff --git a/Core/Sources/HostApp/BYOKSettings/ProviderConfigView.swift b/Core/Sources/HostApp/BYOKSettings/ProviderConfigView.swift
index b29ed3cc..194c4f91 100644
--- a/Core/Sources/HostApp/BYOKSettings/ProviderConfigView.swift
+++ b/Core/Sources/HostApp/BYOKSettings/ProviderConfigView.swift
@@ -83,15 +83,7 @@ struct BYOKProviderConfigView: View {
isSelectedCustomModel = false
}
}
- .cornerRadius(12)
- .clipShape(RoundedRectangle(cornerRadius: 12))
- .overlay(
- RoundedRectangle(cornerRadius: 12)
- .inset(by: 0.5)
- .stroke(SecondarySystemFillColor, lineWidth: 1)
- .animation(.easeInOut(duration: 0.3), value: isExpanded)
- )
- .animation(.easeInOut(duration: 0.3), value: isExpanded)
+ .settingsContainerStyle(isExpanded: isExpanded)
}
// MARK: - UI Components
@@ -150,7 +142,7 @@ struct BYOKProviderConfigView: View {
private var ConfiguredProviderActions: some View {
HStack(spacing: 8) {
if provider.authType == .GlobalApiKey && isExpanded {
- SearchBar(isVisible: $isSearchBarVisible, text: $searchText)
+ CollapsibleSearchField(searchText: $searchText, isExpanded: $isSearchBarVisible)
Button(action: { Task {
await dataManager.listModelsWithFetch(providerName: provider)
diff --git a/Core/Sources/HostApp/CopilotPolicyManager.swift b/Core/Sources/HostApp/CopilotPolicyManager.swift
new file mode 100644
index 00000000..9cf22eff
--- /dev/null
+++ b/Core/Sources/HostApp/CopilotPolicyManager.swift
@@ -0,0 +1,107 @@
+import Client
+import Combine
+import Foundation
+import GitHubCopilotService
+import Logger
+import SwiftUI
+
+/// Centralized manager for GitHub Copilot policies in the HostApp
+/// Use as @StateObject or @ObservedObject in SwiftUI views
+@MainActor
+public class CopilotPolicyManager: ObservableObject {
+ public static let shared = CopilotPolicyManager()
+
+ // MARK: - Published Properties
+
+ @Published public private(set) var isMCPContributionPointEnabled = true
+ @Published public private(set) var isCustomAgentEnabled = true
+ @Published public private(set) var isSubagentEnabled = true
+ @Published public private(set) var isCVERemediatorAgentEnabled = true
+ @Published public private(set) var isAgentModeAutoApprovalEnabled = true
+
+ // MARK: - Private Properties
+
+ private var cancellables = Set()
+ private var lastUpdateTime: Date?
+ private let updateThrottle: TimeInterval = 1.0 // Prevent excessive updates
+
+ // MARK: - Initialization
+
+ private init() {
+ setupNotificationObserver()
+ Task {
+ await updatePolicy()
+ }
+ }
+
+ // MARK: - Public Methods
+
+ /// Manually refresh policies from the service
+ public func refresh() async {
+ await updatePolicy()
+ }
+
+ // MARK: - Private Methods
+
+ private func setupNotificationObserver() {
+ DistributedNotificationCenter.default()
+ .publisher(for: .gitHubCopilotPolicyDidChange)
+ .sink { [weak self] _ in
+ Task { @MainActor [weak self] in
+ await self?.updatePolicy()
+ }
+ }
+ .store(in: &cancellables)
+ }
+
+ private func updatePolicy() async {
+ // Throttle updates to prevent excessive calls
+ if let lastUpdate = lastUpdateTime,
+ Date().timeIntervalSince(lastUpdate) < updateThrottle {
+ return
+ }
+
+ lastUpdateTime = Date()
+
+ do {
+ let service = try getService()
+ guard let policy = try await service.getCopilotPolicy() else {
+ Logger.client.info("Copilot policy returned nil, using defaults")
+ return
+ }
+
+ // Update all policies at once
+ isMCPContributionPointEnabled = policy.mcpContributionPointEnabled
+ isCustomAgentEnabled = policy.customAgentEnabled
+ isSubagentEnabled = policy.subagentEnabled
+ isCVERemediatorAgentEnabled = policy.cveRemediatorAgentEnabled
+ isAgentModeAutoApprovalEnabled = policy.agentModeAutoApprovalEnabled
+
+ Logger.client.info("Copilot policy updated: customAgent=\(policy.customAgentEnabled), mcp=\(policy.mcpContributionPointEnabled), subagent=\(policy.subagentEnabled)")
+ } catch {
+ Logger.client.error("Failed to update copilot policy: \(error.localizedDescription)")
+ }
+ }
+}
+
+// MARK: - Environment Key
+
+private struct CopilotPolicyManagerKey: EnvironmentKey {
+ static let defaultValue = CopilotPolicyManager.shared
+}
+
+public extension EnvironmentValues {
+ var copilotPolicyManager: CopilotPolicyManager {
+ get { self[CopilotPolicyManagerKey.self] }
+ set { self[CopilotPolicyManagerKey.self] = newValue }
+ }
+}
+
+// MARK: - View Extension
+
+public extension View {
+ /// Inject the copilot policy manager into the environment
+ func withCopilotPolicyManager(_ manager: CopilotPolicyManager = .shared) -> some View {
+ self.environment(\.copilotPolicyManager, manager)
+ }
+}
diff --git a/Core/Sources/HostApp/FeatureFlagManager.swift b/Core/Sources/HostApp/FeatureFlagManager.swift
new file mode 100644
index 00000000..189d5a4e
--- /dev/null
+++ b/Core/Sources/HostApp/FeatureFlagManager.swift
@@ -0,0 +1,111 @@
+import Client
+import Combine
+import Foundation
+import GitHubCopilotService
+import Logger
+import SwiftUI
+
+/// Centralized manager for GitHub Copilot feature flags in the HostApp
+/// Use as @StateObject or @ObservedObject in SwiftUI views
+@MainActor
+public class FeatureFlagManager: ObservableObject {
+ public static let shared = FeatureFlagManager()
+
+ // MARK: - Published Properties
+
+ @Published public private(set) var isAgentModeEnabled = true
+ @Published public private(set) var isBYOKEnabled = true
+ @Published public private(set) var isMCPEnabled = true
+ @Published public private(set) var isEditorPreviewEnabled = true
+ @Published public private(set) var isChatEnabled = true
+ @Published public private(set) var isCodeReviewEnabled = true
+ @Published public private(set) var isAgenModeAutoApprovalEnabled = true
+
+ // MARK: - Private Properties
+
+ private var cancellables = Set()
+ private var lastUpdateTime: Date?
+ private let updateThrottle: TimeInterval = 1.0 // Prevent excessive updates
+
+ // MARK: - Initialization
+
+ private init() {
+ setupNotificationObserver()
+ Task {
+ await updateFeatureFlags()
+ }
+ }
+
+ // MARK: - Public Methods
+
+ /// Manually refresh feature flags from the service
+ public func refresh() async {
+ await updateFeatureFlags()
+ }
+
+ // MARK: - Private Methods
+
+ private func setupNotificationObserver() {
+ DistributedNotificationCenter.default()
+ .publisher(for: .gitHubCopilotFeatureFlagsDidChange)
+ .sink { [weak self] _ in
+ Task { @MainActor [weak self] in
+ await self?.updateFeatureFlags()
+ }
+ }
+ .store(in: &cancellables)
+ }
+
+ private func updateFeatureFlags() async {
+ // Throttle updates to prevent excessive calls
+ if let lastUpdate = lastUpdateTime,
+ Date().timeIntervalSince(lastUpdate) < updateThrottle {
+ return
+ }
+
+ lastUpdateTime = Date()
+
+ do {
+ let service = try getService()
+ guard let featureFlags = try await service.getCopilotFeatureFlags() else {
+ Logger.client.info("Feature flags returned nil, using defaults")
+ return
+ }
+
+ // Update all flags at once
+ isAgentModeEnabled = featureFlags.agentMode
+ isBYOKEnabled = featureFlags.byok
+ isMCPEnabled = featureFlags.mcp
+ isEditorPreviewEnabled = featureFlags.editorPreviewFeatures
+ isChatEnabled = featureFlags.chat
+ isCodeReviewEnabled = featureFlags.ccr
+ isAgenModeAutoApprovalEnabled = featureFlags.agentModeAutoApproval
+
+ Logger.client.info("Feature flags updated: agentMode=\(featureFlags.agentMode), byok=\(featureFlags.byok), mcp=\(featureFlags.mcp), editorPreview=\(featureFlags.editorPreviewFeatures)")
+ } catch {
+ Logger.client.error("Failed to update feature flags: \(error.localizedDescription)")
+ }
+ }
+}
+
+// MARK: - Environment Key
+
+private struct FeatureFlagManagerKey: EnvironmentKey {
+ static let defaultValue = FeatureFlagManager.shared
+}
+
+public extension EnvironmentValues {
+ var featureFlagManager: FeatureFlagManager {
+ get { self[FeatureFlagManagerKey.self] }
+ set { self[FeatureFlagManagerKey.self] = newValue }
+ }
+}
+
+// MARK: - View Extension
+
+public extension View {
+ /// Inject the feature flag manager into the environment
+ func withFeatureFlagManager(_ manager: FeatureFlagManager = .shared) -> some View {
+ self.environment(\.featureFlagManager, manager)
+ }
+}
diff --git a/Core/Sources/HostApp/GeneralSettings/CopilotConnectionView.swift b/Core/Sources/HostApp/GeneralSettings/CopilotConnectionView.swift
index 5a454b7a..81f7b9fc 100644
--- a/Core/Sources/HostApp/GeneralSettings/CopilotConnectionView.swift
+++ b/Core/Sources/HostApp/GeneralSettings/CopilotConnectionView.swift
@@ -57,7 +57,10 @@ struct CopilotConnectionView: View {
isPresented: $viewModel.isSignInAlertPresented,
presenting: viewModel.signInResponse) { _ in
Button("Cancel", role: .cancel, action: {})
- Button("Copy Code and Open", action: viewModel.copyAndOpen)
+ Button(
+ "Copy Code and Open",
+ action: { viewModel.copyAndOpen(fromHostApp: true) }
+ )
} message: { response in
Text("""
Please enter the above code in the \
@@ -117,7 +120,7 @@ struct CopilotConnectionView: View {
)
Divider()
SettingsLink(
- url: "https://github.com/orgs/community/discussions/categories/copilot",
+ url: "https://github.com/github/CopilotForXcode/discussions",
title: "View Copilot Feedback Forum"
)
}
diff --git a/Core/Sources/HostApp/GeneralSettings/GeneralSettingsView.swift b/Core/Sources/HostApp/GeneralSettings/GeneralSettingsView.swift
index 6c264821..19418245 100644
--- a/Core/Sources/HostApp/GeneralSettings/GeneralSettingsView.swift
+++ b/Core/Sources/HostApp/GeneralSettings/GeneralSettingsView.swift
@@ -1,5 +1,6 @@
import ComposableArchitecture
import SwiftUI
+import SharedUIComponents
struct GeneralSettingsView: View {
@AppStorage(\.extensionPermissionShown) var extensionPermissionShown: Bool
@@ -19,7 +20,7 @@ struct GeneralSettingsView: View {
return ""
}
}
-
+
var extensionPermissionSubtitle: any View {
switch store.isExtensionPermissionGranted {
case .notGranted:
@@ -40,8 +41,7 @@ struct GeneralSettingsView: View {
return Text("")
}
}
-
-
+
var extensionPermissionBadge: BadgeItem? {
switch store.isExtensionPermissionGranted {
case .notGranted:
@@ -52,8 +52,8 @@ struct GeneralSettingsView: View {
return nil
}
}
-
- var extensionPermissionAction: ()->Void {
+
+ var extensionPermissionAction: () -> Void {
switch store.isExtensionPermissionGranted {
case .disabled:
return { shouldShowRestartXcodeAlert = true }
@@ -89,12 +89,9 @@ struct GeneralSettingsView: View {
} footer: {
HStack {
Spacer()
- Button("?") {
- NSWorkspace.shared.open(
- URL(string: "https://github.com/github/CopilotForXcode/blob/main/TROUBLESHOOTING.md")!
- )
- }
- .clipShape(Circle())
+ AdaptiveHelpLink(action: { NSWorkspace.shared.open(
+ URL(string: "https://github.com/github/CopilotForXcode/blob/main/TROUBLESHOOTING.md")!
+ )})
}
}
.alert(
@@ -102,10 +99,10 @@ struct GeneralSettingsView: View {
isPresented: $shouldPresentExtensionPermissionAlert
) {
Button(
- "Open System Preferences",
- action: {
- NSWorkspace.openXcodeExtensionsPreferences()
- }).keyboardShortcut(.defaultAction)
+ "Open System Preferences",
+ action: {
+ NSWorkspace.openXcodeExtensionsPreferences()
+ }).keyboardShortcut(.defaultAction)
Button("View How-to Guide", action: {
let url = "https://github.com/github/CopilotForXcode/blob/main/TROUBLESHOOTING.md#extension-permission"
NSWorkspace.shared.open(URL(string: url)!)
@@ -126,7 +123,7 @@ struct GeneralSettingsView: View {
Button("Restart Now") {
NSWorkspace.restartXcode()
}.keyboardShortcut(.defaultAction)
-
+
Button("Cancel", role: .cancel) {}
} message: {
Text("Quit and restart Xcode to enable Github Copilot for Xcode extension.")
diff --git a/Core/Sources/HostApp/HostApp.swift b/Core/Sources/HostApp/HostApp.swift
index e9d2253e..ba3c3da7 100644
--- a/Core/Sources/HostApp/HostApp.swift
+++ b/Core/Sources/HostApp/HostApp.swift
@@ -39,18 +39,25 @@ public enum TabIndex: Int, CaseIterable {
}
}
+public enum ToolsSubTab: String, CaseIterable, Identifiable {
+ case MCP, BuiltIn, AutoApprove
+ public var id: Self { self }
+}
+
@Reducer
public struct HostApp {
@ObservableState
public struct State: Equatable {
var general = General.State()
public var activeTabIndex: TabIndex = .general
+ public var activeToolsSubTab: ToolsSubTab = .MCP
}
public enum Action: Equatable {
case appear
case general(General.Action)
case setActiveTab(TabIndex)
+ case setActiveToolsSubTab(ToolsSubTab)
}
@Dependency(\.toast) var toast
@@ -75,6 +82,10 @@ public struct HostApp {
case .setActiveTab(let index):
state.activeTabIndex = index
return .none
+
+ case .setActiveToolsSubTab(let tab):
+ state.activeToolsSubTab = tab
+ return .none
}
}
}
diff --git a/Core/Sources/HostApp/SharedComponents/Badge.swift b/Core/Sources/HostApp/SharedComponents/Badge.swift
index 615d03e9..7c0b2e03 100644
--- a/Core/Sources/HostApp/SharedComponents/Badge.swift
+++ b/Core/Sources/HostApp/SharedComponents/Badge.swift
@@ -11,33 +11,50 @@ struct BadgeItem {
let level: Level
let icon: String?
let isSelected: Bool
+ let tooltip: String?
- init(text: String, level: Level, icon: String? = nil, isSelected: Bool = false) {
+ init(text: String, level: Level, icon: String? = nil, isSelected: Bool = false, tooltip: String? = nil) {
self.text = text
self.level = level
self.icon = icon
self.isSelected = isSelected
+ self.tooltip = tooltip
}
}
struct Badge: View {
let text: String
+ let attributedText: AttributedString?
let level: BadgeItem.Level
let icon: String?
let isSelected: Bool
+ let tooltip: String?
init(badgeItem: BadgeItem) {
text = badgeItem.text
+ attributedText = nil
level = badgeItem.level
icon = badgeItem.icon
isSelected = badgeItem.isSelected
+ tooltip = badgeItem.tooltip
}
- init(text: String, level: BadgeItem.Level, icon: String? = nil, isSelected: Bool = false) {
+ init(text: String, level: BadgeItem.Level, icon: String? = nil, isSelected: Bool = false, tooltip: String? = nil) {
self.text = text
+ self.attributedText = nil
self.level = level
self.icon = icon
self.isSelected = isSelected
+ self.tooltip = tooltip
+ }
+
+ init(attributedText: AttributedString, level: BadgeItem.Level, icon: String? = nil, isSelected: Bool = false, tooltip: String? = nil) {
+ self.text = String(attributedText.characters)
+ self.attributedText = attributedText
+ self.level = level
+ self.icon = icon
+ self.isSelected = isSelected
+ self.tooltip = tooltip
}
var body: some View {
@@ -47,10 +64,18 @@ struct Badge: View {
.font(.caption2)
.padding(.vertical, 1)
}
- Text(text)
- .fontWeight(.semibold)
- .font(.caption2)
- .lineLimit(1)
+ if let attributedText = attributedText, attributedText.characters.count > 0 {
+ Text(attributedText)
+ .fontWeight(.semibold)
+ .font(.caption2)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ } else if !text.isEmpty {
+ Text(text)
+ .fontWeight(.semibold)
+ .font(.caption2)
+ .lineLimit(1)
+ }
}
.padding(.vertical, 1)
.padding(.horizontal, 3)
@@ -77,5 +102,20 @@ struct Badge: View {
lineWidth: 1
)
)
+ .help(tooltip ?? text)
+ }
+}
+
+extension BadgeItem {
+ static func disabledByPolicy(feature: String, isPlural: Bool = false) -> BadgeItem {
+ let verb = isPlural ? "are" : "is"
+ let pronoun = isPlural ? "them" : "it"
+ return .init(
+ text: "Disabled by organization policy",
+ level: .warning,
+ icon: "exclamationmark.triangle.fill",
+ tooltip: "\(feature) \(verb) disabled by your organization's policy. Please contact your administrator to enable \(pronoun)."
+ )
}
}
+
diff --git a/Core/Sources/HostApp/SharedComponents/CardGroupBoxStyle.swift b/Core/Sources/HostApp/SharedComponents/CardGroupBoxStyle.swift
index 1cc98444..7ab60d87 100644
--- a/Core/Sources/HostApp/SharedComponents/CardGroupBoxStyle.swift
+++ b/Core/Sources/HostApp/SharedComponents/CardGroupBoxStyle.swift
@@ -1,4 +1,5 @@
import SwiftUI
+import SharedUIComponents
public struct CardGroupBoxStyle: GroupBoxStyle {
public var backgroundColor: Color
@@ -15,7 +16,8 @@ public struct CardGroupBoxStyle: GroupBoxStyle {
configuration.label.foregroundColor(.primary)
configuration.content.foregroundColor(.primary)
}
- .padding(8)
+ .padding(.vertical, 12)
+ .padding(.horizontal, 20)
.frame(maxWidth: .infinity, alignment: .topLeading)
.background(backgroundColor)
.cornerRadius(12)
diff --git a/Core/Sources/HostApp/SharedComponents/Color.swift b/Core/Sources/HostApp/SharedComponents/Color.swift
deleted file mode 100644
index 2d5a7682..00000000
--- a/Core/Sources/HostApp/SharedComponents/Color.swift
+++ /dev/null
@@ -1,33 +0,0 @@
-import SwiftUI
-
-public var QuinarySystemFillColor: Color {
- if #available(macOS 14.0, *) {
- return Color(nsColor: .quinarySystemFill)
- } else {
- return Color("QuinarySystemFillColor")
- }
-}
-
-public var QuaternarySystemFillColor: Color {
- if #available(macOS 14.0, *) {
- return Color(nsColor: .quaternarySystemFill)
- } else {
- return Color("QuaternarySystemFillColor")
- }
-}
-
-public var TertiarySystemFillColor: Color {
- if #available(macOS 14.0, *) {
- return Color(nsColor: .tertiarySystemFill)
- } else {
- return Color("TertiarySystemFillColor")
- }
-}
-
-public var SecondarySystemFillColor: Color {
- if #available(macOS 14.0, *) {
- return Color(nsColor: .secondarySystemFill)
- } else {
- return Color("SecondarySystemFillColor")
- }
-}
diff --git a/Core/Sources/HostApp/SharedComponents/DisclosureSettingsRow.swift b/Core/Sources/HostApp/SharedComponents/DisclosureSettingsRow.swift
index 38559dcc..6567985c 100644
--- a/Core/Sources/HostApp/SharedComponents/DisclosureSettingsRow.swift
+++ b/Core/Sources/HostApp/SharedComponents/DisclosureSettingsRow.swift
@@ -1,4 +1,5 @@
import SwiftUI
+import SharedUIComponents
public struct DisclosureSettingsRow: View {
@Binding private var isExpanded: Bool
@@ -22,7 +23,7 @@ public struct DisclosureSettingsRow:
onToggle: ((Bool, Bool) -> Void)? = nil,
@ViewBuilder title: @escaping () -> Title,
@ViewBuilder subtitle: @escaping (() -> Subtitle) = { EmptyView() },
- @ViewBuilder actions: @escaping () -> Actions
+ @ViewBuilder actions: @escaping () -> Actions = { EmptyView() }
) {
_isExpanded = isExpanded
self.isEnabled = isEnabled
diff --git a/Core/Sources/HostApp/SharedComponents/EditableText.swift b/Core/Sources/HostApp/SharedComponents/EditableText.swift
new file mode 100644
index 00000000..41db896a
--- /dev/null
+++ b/Core/Sources/HostApp/SharedComponents/EditableText.swift
@@ -0,0 +1,55 @@
+import SwiftUI
+import Perception
+
+struct EditableText: View {
+ let title: String
+ let initialText: String
+ let onCommit: (String) -> Bool
+
+ @State private var text: String
+ @State private var lastCommittedText: String
+ @State private var isReverting: Bool = false
+
+ init(_ title: String, text: String, onCommit: @escaping (String) -> Bool) {
+ self.title = title
+ self.initialText = text
+ self._text = State(initialValue: text)
+ self._lastCommittedText = State(initialValue: text)
+ self.onCommit = onCommit
+ }
+
+ var body: some View {
+ TextField(title, text: $text, onEditingChanged: { editing in
+ if !editing {
+ commit()
+ }
+ })
+ .onSubmit {
+ commit()
+ }
+ .onChange(of: initialText) { newValue in
+ if text != newValue {
+ text = newValue
+ }
+ if lastCommittedText != newValue {
+ lastCommittedText = newValue
+ }
+ }
+ }
+
+ private func commit() {
+ guard !isReverting else { return }
+ guard text != lastCommittedText else { return }
+
+ if onCommit(text) {
+ lastCommittedText = text
+ } else {
+ isReverting = true
+ // Async revert to ensure textField updates even during focus change
+ DispatchQueue.main.async {
+ text = lastCommittedText
+ isReverting = false
+ }
+ }
+ }
+}
diff --git a/Core/Sources/HostApp/SharedComponents/SearchBar.swift b/Core/Sources/HostApp/SharedComponents/SearchBar.swift
deleted file mode 100644
index 5104d29c..00000000
--- a/Core/Sources/HostApp/SharedComponents/SearchBar.swift
+++ /dev/null
@@ -1,100 +0,0 @@
-import SharedUIComponents
-import SwiftUI
-
-/// Reusable search control with a toggleable magnifying glass button that expands
-/// into a styled search field with clear button, focus handling, and auto-hide
-/// when focus is lost and the text is empty.
-///
-/// Usage:
-/// SearchBar(isVisible: $isSearchBarVisible, text: $searchText)
-struct SearchBar: View {
- @Binding var isVisible: Bool
- @Binding var text: String
-
- @FocusState private var isFocused: Bool
-
- var placeholder: String = "Search..."
- var accessibilityIdentifier: String = "searchTextField"
-
- var body: some View {
- Group {
- if isVisible {
- HStack(spacing: 5) {
- Image(systemName: "magnifyingglass")
- .foregroundColor(.secondary)
- .onTapGesture { withAnimation(.easeInOut) {
- isVisible = false
- } }
-
- TextField(placeholder, text: $text)
- .accessibilityIdentifier(accessibilityIdentifier)
- .textFieldStyle(PlainTextFieldStyle())
- .focused($isFocused)
-
- if !text.isEmpty {
- Button(action: { text = "" }) {
- Image(systemName: "xmark.circle.fill")
- .foregroundColor(.secondary)
- }
- .buttonStyle(PlainButtonStyle())
- .help("Clear search")
- }
- }
- .padding(.leading, 7)
- .padding(.trailing, 3)
- .padding(.vertical, 3)
- .background(
- RoundedRectangle(cornerRadius: 5)
- .fill(Color(.textBackgroundColor))
- )
- .overlay(
- RoundedRectangle(cornerRadius: 5)
- .stroke(
- isFocused
- ? Color(red: 0, green: 0.48, blue: 1).opacity(0.5)
- : Color.gray.opacity(0.4),
- lineWidth: isFocused ? 3 : 1
- )
- )
- .cornerRadius(5)
- .frame(width: 212, height: 20, alignment: .leading)
- .shadow(color: Color(red: 0, green: 0.48, blue: 1).opacity(0.5), radius: isFocused ? 1.25 : 0, x: 0, y: 0)
- .shadow(color: .black.opacity(0.05), radius: 0, x: 0, y: 0)
- .shadow(color: .black.opacity(0.3), radius: 1.25, x: 0, y: 0.5)
- .padding(2)
- // Removed the move(edge: .trailing) to prevent overlap; keep a clean fade instead
- .transition(.asymmetric(insertion: .opacity, removal: .opacity))
- .onChange(of: isFocused) { focused in
- if !focused && text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
- withAnimation(.easeInOut) {
- isVisible = false
- }
- }
- }
- .onChange(of: isVisible) { newValue in
- if newValue {
- // Delay to ensure the field is mounted before requesting focus.
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
- isFocused = true
- }
- }
- }
- } else {
- Button(action: {
- withAnimation(.easeInOut) {
- isVisible = true
- }
- }) {
- Image(systemName: "magnifyingglass")
- .padding(.trailing, 2)
- }
- .buttonStyle(HoverButtonStyle())
- .frame(height: 24)
- .transition(.opacity)
- .help("Show search")
- }
- }
- .contentShape(Rectangle())
- .onTapGesture { if isFocused { isFocused = false } }
- }
-}
diff --git a/Core/Sources/HostApp/SharedComponents/SettingsContainerStyle.swift b/Core/Sources/HostApp/SharedComponents/SettingsContainerStyle.swift
new file mode 100644
index 00000000..119edd80
--- /dev/null
+++ b/Core/Sources/HostApp/SharedComponents/SettingsContainerStyle.swift
@@ -0,0 +1,17 @@
+import SwiftUI
+import SharedUIComponents
+
+extension View {
+ func settingsContainerStyle(isExpanded: Bool) -> some View {
+ self
+ .cornerRadius(12)
+ .clipShape(RoundedRectangle(cornerRadius: 12))
+ .overlay(
+ RoundedRectangle(cornerRadius: 12)
+ .inset(by: 0.5)
+ .stroke(SecondarySystemFillColor, lineWidth: 1)
+ .animation(.easeInOut(duration: 0.3), value: isExpanded)
+ )
+ .animation(.easeInOut(duration: 0.3), value: isExpanded)
+ }
+}
diff --git a/Core/Sources/HostApp/SharedComponents/SettingsToggle.swift b/Core/Sources/HostApp/SharedComponents/SettingsToggle.swift
index 2576dc1c..a3dc805d 100644
--- a/Core/Sources/HostApp/SharedComponents/SettingsToggle.swift
+++ b/Core/Sources/HostApp/SharedComponents/SettingsToggle.swift
@@ -4,11 +4,33 @@ struct SettingsToggle: View {
static let defaultPadding: CGFloat = 10
let title: String
+ let subtitle: String?
let isOn: Binding
+ let badge: BadgeItem?
+
+ init(title: String, subtitle: String? = nil, isOn: Binding, badge: BadgeItem? = nil) {
+ self.title = title
+ self.subtitle = subtitle
+ self.isOn = isOn
+ self.badge = badge
+ }
var body: some View {
HStack(alignment: .center) {
- Text(title)
+ VStack(alignment: .leading) {
+ HStack(spacing: 6) {
+ Text(title).font(.body)
+
+ if let badge = badge {
+ Badge(badgeItem: badge)
+ .allowsHitTesting(true)
+ }
+ }
+
+ if let subtitle = subtitle {
+ Text(subtitle).font(.footnote)
+ }
+ }
Spacer()
Toggle(isOn: isOn) {}
.controlSize(.mini)
diff --git a/Core/Sources/HostApp/SharedComponents/TransparentTableBackground.swift b/Core/Sources/HostApp/SharedComponents/TransparentTableBackground.swift
new file mode 100644
index 00000000..c672e420
--- /dev/null
+++ b/Core/Sources/HostApp/SharedComponents/TransparentTableBackground.swift
@@ -0,0 +1,12 @@
+import SwiftUI
+
+extension View {
+ @ViewBuilder
+ func transparentBackground() -> some View {
+ if #available(macOS 14.0, *) {
+ self.scrollContentBackground(.hidden).alternatingRowBackgrounds(.disabled)
+ } else {
+ self
+ }
+ }
+}
diff --git a/Core/Sources/HostApp/TabContainer.swift b/Core/Sources/HostApp/TabContainer.swift
index 3b81636a..c4a372cd 100644
--- a/Core/Sources/HostApp/TabContainer.swift
+++ b/Core/Sources/HostApp/TabContainer.swift
@@ -15,9 +15,8 @@ public let hostAppStore: StoreOf = .init(initialState: .init(), reducer
public struct TabContainer: View {
let store: StoreOf
@ObservedObject var toastController: ToastController
+ @ObservedObject private var featureFlags = FeatureFlagManager.shared
@State private var tabBarItems = [TabBarItem]()
- @State private var isAgentModeFFEnabled = true
- @State private var isBYOKFFEnabled = true
@Binding var tag: TabIndex
public init() {
@@ -37,23 +36,6 @@ public struct TabContainer: View {
set: { store.send(.setActiveTab($0)) }
)
}
-
- private func updateHostAppFeatureFlags() async {
- do {
- let service = try getService()
- let featureFlags = try await service.getCopilotFeatureFlags()
- isAgentModeFFEnabled = featureFlags?.agentMode ?? true
- isBYOKFFEnabled = featureFlags?.byok ?? true
- if hostAppStore.state.activeTabIndex == .tools && !isAgentModeFFEnabled {
- hostAppStore.send(.setActiveTab(.general))
- }
- if hostAppStore.state.activeTabIndex == .byok && !isBYOKFFEnabled {
- hostAppStore.send(.setActiveTab(.general))
- }
- } catch {
- Logger.client.error("Failed to get copilot feature flags: \(error)")
- }
- }
public var body: some View {
WithPerceptionTracking {
@@ -63,10 +45,10 @@ public struct TabContainer: View {
ZStack(alignment: .center) {
GeneralView(store: store.scope(state: \.general, action: \.general)).tabBarItem(for: .general)
AdvancedSettings().tabBarItem(for: .advanced)
- if isAgentModeFFEnabled {
+ if featureFlags.isAgentModeEnabled {
MCPConfigView().tabBarItem(for: .tools)
}
- if isBYOKFFEnabled {
+ if featureFlags.isBYOKEnabled {
BYOKConfigView().tabBarItem(for: .byok)
}
}
@@ -82,16 +64,17 @@ public struct TabContainer: View {
}
.onAppear {
store.send(.appear)
- Task {
- await updateHostAppFeatureFlags()
+ }
+ .onChange(of: featureFlags.isAgentModeEnabled) { isEnabled in
+ if hostAppStore.state.activeTabIndex == .tools && !isEnabled {
+ hostAppStore.send(.setActiveTab(.general))
}
}
- .onReceive(DistributedNotificationCenter.default()
- .publisher(for: .gitHubCopilotFeatureFlagsDidChange)) { _ in
- Task {
- await updateHostAppFeatureFlags()
- }
+ .onChange(of: featureFlags.isBYOKEnabled) { isEnabled in
+ if hostAppStore.state.activeTabIndex == .byok && !isEnabled {
+ hostAppStore.send(.setActiveTab(.general))
}
+ }
}
}
}
diff --git a/Core/Sources/HostApp/ToolsConfigView.swift b/Core/Sources/HostApp/ToolsConfigView.swift
index 16b1bb8e..6ece9ade 100644
--- a/Core/Sources/HostApp/ToolsConfigView.swift
+++ b/Core/Sources/HostApp/ToolsConfigView.swift
@@ -1,92 +1,119 @@
import Client
+import ComposableArchitecture
+import ConversationServiceProvider
import Foundation
+import GitHubCopilotService
import Logger
+import Persist
import SharedUIComponents
import SwiftUI
+import SystemUtils
import Toast
-import ConversationServiceProvider
-import GitHubCopilotService
-import ComposableArchitecture
struct MCPConfigView: View {
@State private var mcpConfig: String = ""
@Environment(\.toast) var toast
+ @ObservedObject private var featureFlags = FeatureFlagManager.shared
+ @ObservedObject private var copilotPolicy = CopilotPolicyManager.shared
@State private var configFilePath: String = mcpConfigFilePath
@State private var isMonitoring: Bool = false
@State private var lastModificationDate: Date? = nil
@State private var fileMonitorTask: Task? = nil
- @State private var isMCPFFEnabled = false
- @State private var selectedOption = ToolType.MCP
+ @State private var selectedMode: ConversationMode = .defaultAgent
@Environment(\.colorScheme) var colorScheme
- private static var lastSyncTimestamp: Date? = nil
-
- enum ToolType: String, CaseIterable, Identifiable {
- case MCP, BuiltIn
- var id: Self { self }
+ private var isCustomAgentEnabled: Bool {
+ copilotPolicy.isCustomAgentEnabled
}
+ private static var lastSyncTimestamp: Date? = nil
+ @State private var debounceTimer: Timer?
+ private static let refreshDebounceInterval: TimeInterval = 1.0 // 1.0 second debounce
+
var body: some View {
WithPerceptionTracking {
ScrollView {
- Picker("", selection: $selectedOption) {
- Text("MCP").tag(ToolType.MCP)
- Text("Built-In").tag(ToolType.BuiltIn)
+ Picker("", selection: Binding(
+ get: { hostAppStore.state.activeToolsSubTab },
+ set: { hostAppStore.send(.setActiveToolsSubTab($0)) }
+ )) {
+ if #available(macOS 26.0, *) {
+ Text("MCP".padded(centerTo: 24, with: "\u{2002}")).tag(ToolsSubTab.MCP)
+ Text("Built-In".padded(centerTo: 24, with: "\u{2002}")).tag(ToolsSubTab.BuiltIn)
+ Text("Auto-Approve".padded(centerTo: 24, with: "\u{2002}")).tag(ToolsSubTab.AutoApprove)
+ } else {
+ Text("MCP").tag(ToolsSubTab.MCP)
+ Text("Built-In").tag(ToolsSubTab.BuiltIn)
+ Text("Auto-Approve").tag(ToolsSubTab.AutoApprove)
+ }
}
- .pickerStyle(.segmented)
.frame(width: 400)
-
+ .labelsHidden()
+ .pickerStyle(.segmented)
+ .padding(.top, 12)
+ .padding(.bottom, 4)
+
Group {
- if selectedOption == .MCP {
+ if hostAppStore.activeToolsSubTab == .MCP {
VStack(alignment: .leading, spacing: 8) {
- MCPIntroView(isMCPFFEnabled: $isMCPFFEnabled)
- if isMCPFFEnabled {
+ MCPIntroView(isMCPFFEnabled: featureFlags.isMCPEnabled)
+ if featureFlags.isMCPEnabled {
MCPManualInstallView()
- MCPToolsListView()
+
+ if featureFlags.isEditorPreviewEnabled {
+ MCPRegistryURLView()
+ }
+
+ MCPXcodeServerInstallView()
+
+ MCPToolsListView(
+ selectedMode: $selectedMode,
+ isCustomAgentEnabled: isCustomAgentEnabled
+ )
+
+ HStack {
+ Spacer()
+ AdaptiveHelpLink(action: { NSWorkspace.shared.open(
+ URL(string: "https://modelcontextprotocol.io/introduction")!
+ ) })
+ }
}
}
.onAppear {
setupConfigFilePath()
- Task {
- await updateMCPFeatureFlag()
+ if featureFlags.isMCPEnabled {
+ startMonitoringConfigFile()
}
}
.onDisappear {
stopMonitoringConfigFile()
}
- .onChange(of: isMCPFFEnabled) { newMCPFFEnabled in
+ .onChange(of: featureFlags.isMCPEnabled) { newMCPFFEnabled in
if newMCPFFEnabled {
startMonitoringConfigFile()
- refreshConfiguration(())
+ refreshConfiguration()
} else {
stopMonitoringConfigFile()
}
}
- .onReceive(DistributedNotificationCenter.default()
- .publisher(for: .gitHubCopilotFeatureFlagsDidChange)) { _ in
- Task {
- await updateMCPFeatureFlag()
- }
+ .onChange(of: isCustomAgentEnabled) { isEnabled in
+ if !isEnabled && !selectedMode.isDefaultAgent {
+ selectedMode = .defaultAgent
}
+ }
+ } else if hostAppStore.activeToolsSubTab == .BuiltIn {
+ BuiltInToolsListView(
+ selectedMode: $selectedMode,
+ isCustomAgentEnabled: isCustomAgentEnabled
+ )
} else {
- BuiltInToolsListView()
+ AutoApproveContainerView()
}
}
- .padding(20)
+ .padding(.horizontal, 20)
}
}
}
-
- private func updateMCPFeatureFlag() async {
- do {
- let service = try getService()
- if let featureFlags = try await service.getCopilotFeatureFlags() {
- isMCPFFEnabled = featureFlags.mcp
- }
- } catch {
- Logger.client.error("Failed to get copilot feature flags: \(error)")
- }
- }
private func setupConfigFilePath() {
let fileManager = FileManager.default
@@ -101,7 +128,7 @@ struct MCPConfigView: View {
try? """
{
"servers": {
-
+
}
}
""".write(to: configFileURL, atomically: true, encoding: .utf8)
@@ -153,48 +180,56 @@ struct MCPConfigView: View {
}
private func startMonitoringConfigFile() {
- stopMonitoringConfigFile() // Stop existing monitoring if any
+ stopMonitoringConfigFile() // Stop existing monitoring if any
isMonitoring = true
+ Logger.client.info("Starting MCP config file monitoring")
fileMonitorTask = Task {
let configFileURL = URL(fileURLWithPath: configFilePath)
// Check for file changes periodically
while isMonitoring {
- try? await Task.sleep(nanoseconds: 3_000_000_000) // Check every 3 seconds
+ try? await Task.sleep(nanoseconds: 3_000_000_000) // Check every 3 second for better responsiveness
+
+ guard isMonitoring else { break } // Extra check after sleep
let currentDate = getFileModificationDate(url: configFileURL)
if let currentDate = currentDate, currentDate != lastModificationDate {
// File modification date has changed, update our record
+ Logger.client.info("MCP config file change detected")
lastModificationDate = currentDate
// Read and validate the updated content
if let validJson = readAndValidateJSON(from: configFileURL) {
await MainActor.run {
mcpConfig = validJson
- refreshConfiguration(validJson)
+ refreshConfiguration()
toast("MCP configuration file updated", .info)
}
} else {
// If JSON is invalid, show error
await MainActor.run {
toast("Invalid JSON in MCP configuration file", .error)
+ Logger.client.info("Invalid JSON detected during monitoring")
}
}
}
}
+ Logger.client.info("Stopped MCP config file monitoring")
}
}
private func stopMonitoringConfigFile() {
+ guard isMonitoring else { return }
+ Logger.client.info("Stopping MCP config file monitoring")
isMonitoring = false
fileMonitorTask?.cancel()
fileMonitorTask = nil
}
- func refreshConfiguration(_: Any) {
+ func refreshConfiguration() {
if MCPConfigView.lastSyncTimestamp == lastModificationDate {
return
}
@@ -206,22 +241,35 @@ struct MCPConfigView: View {
UserDefaults.shared.set(jsonString, for: \.gitHubCopilotMCPConfig)
}
- Task {
- do {
- let service = try getService()
- try await service.postNotification(
- name: Notification.Name
- .gitHubCopilotShouldRefreshEditorInformation.rawValue
- )
- toast("MCP configuration updated", .info)
- } catch {
- toast(error.localizedDescription, .error)
+ // Debounce the refresh notification to avoid sending too frequently
+ debounceTimer?.invalidate()
+ debounceTimer = Timer.scheduledTimer(withTimeInterval: MCPConfigView.refreshDebounceInterval, repeats: false) { _ in
+ Task {
+ do {
+ let service = try getService()
+ try await service.postNotification(
+ name: Notification.Name
+ .gitHubCopilotShouldRefreshEditorInformation.rawValue
+ )
+ await MainActor.run {
+ toast("Fetching MCP tools...", .info)
+ }
+ } catch {
+ await MainActor.run {
+ toast(error.localizedDescription, .error)
+ }
+ }
}
}
}
}
-#Preview {
- MCPConfigView()
- .frame(width: 800, height: 600)
+extension String {
+ func padded(centerTo total: Int, with pad: Character = " ") -> String {
+ guard count < total else { return self }
+ let deficit = total - count
+ let left = deficit / 2
+ let right = deficit - left
+ return String(repeating: pad, count: left) + self + String(repeating: pad, count: right)
+ }
}
diff --git a/Core/Sources/HostApp/ToolsSettings/AgentModeDescriptionView.swift b/Core/Sources/HostApp/ToolsSettings/AgentModeDescriptionView.swift
new file mode 100644
index 00000000..3ae5239e
--- /dev/null
+++ b/Core/Sources/HostApp/ToolsSettings/AgentModeDescriptionView.swift
@@ -0,0 +1,34 @@
+import SwiftUI
+import ConversationServiceProvider
+
+struct AgentModeDescription {
+ static func descriptionText(for mode: ConversationMode) -> String {
+ // Check if it's the built-in "Agent" mode
+ if mode.isDefaultAgent {
+ return "The selected tools will be applied globally for all chat sessions that use the default agent."
+ }
+
+ // Check if it's a custom mode
+ if !mode.isBuiltIn {
+ return "The selected tools are configured by the '\(mode.name)' custom agent. Changes to the tools will be applied to the custom agent file as well."
+ }
+
+ // Other built-in modes (like Plan, etc.)
+ return "The selected tools are configured by the '\(mode.name)' agent. Changes to the tools are not allowed for now."
+ }
+}
+
+/// Shared description view for agent modes
+struct AgentModeDescriptionView: View {
+ let selectedMode: ConversationMode
+ let isLoadingMode: Bool
+
+ var body: some View {
+ if !isLoadingMode {
+ Text(AgentModeDescription.descriptionText(for: selectedMode))
+ .font(.subheadline)
+ .foregroundColor(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+}
diff --git a/Core/Sources/HostApp/ToolsSettings/AgentModeDropdownView.swift b/Core/Sources/HostApp/ToolsSettings/AgentModeDropdownView.swift
new file mode 100644
index 00000000..69a028d9
--- /dev/null
+++ b/Core/Sources/HostApp/ToolsSettings/AgentModeDropdownView.swift
@@ -0,0 +1,87 @@
+import Client
+import ConversationServiceProvider
+import HostAppActivator
+import Logger
+import Persist
+import SwiftUI
+
+struct AgentModeDropdown: View {
+ @Binding var modes: [ConversationMode]
+ @Binding var selectedMode: ConversationMode
+
+ public init(modes: Binding<[ConversationMode]>, selectedMode: Binding) {
+ _modes = modes
+ _selectedMode = selectedMode
+ }
+
+ var builtInModes: [ConversationMode] {
+ modes.filter { $0.isBuiltIn }
+ }
+
+ var customModes: [ConversationMode] {
+ modes.filter { !$0.isBuiltIn }
+ }
+
+ var body: some View {
+ Picker(selection: Binding(
+ get: { selectedMode.id },
+ set: { newId in
+ if let mode = modes.first(where: { $0.id == newId }) {
+ selectedMode = mode
+ }
+ }
+ )) {
+ ForEach(builtInModes, id: \.id) { mode in
+ Text(mode.name).tag(mode.id)
+ }
+
+ if !customModes.isEmpty {
+ Divider()
+ ForEach(customModes, id: \.id) { mode in
+ Text(mode.name).tag(mode.id)
+ }
+ }
+ } label: {
+ Text("Applied for").fontWeight(.bold)
+ }
+ .pickerStyle(.menu)
+ .frame(maxWidth: 300, alignment: .leading)
+ .padding(.leading, -4)
+ .onAppear {
+ loadModes()
+ }
+ .onReceive(DistributedNotificationCenter.default().publisher(for: .selectedAgentSubModeDidChange)) { notification in
+ if let userInfo = notification.userInfo as? [String: String],
+ let newModeId = userInfo["agentSubMode"],
+ newModeId != selectedMode.id,
+ let mode = modes.first(where: { $0.id == newModeId }) {
+ Logger.client.info("AgentModeDropdown: Mode changed to: \(newModeId)")
+ selectedMode = mode
+ }
+ }
+ }
+
+ // MARK: - Helper Methods
+
+ private func loadModes() {
+ Task {
+ do {
+ let service = try getService()
+ let workspaceFolders = await getWorkspaceFolders()
+ if let fetchedModes = try await service.getModes(workspaceFolders: workspaceFolders) {
+ Logger.client.info("AgentModeDropdown: Fetched \(fetchedModes.count) modes")
+ await MainActor.run {
+ modes = fetchedModes.filter { $0.kind == .Agent }
+
+ if !modes.contains(where: { $0.id == selectedMode.id }),
+ let firstMode = modes.first {
+ selectedMode = firstMode
+ }
+ }
+ }
+ } catch {
+ Logger.client.error("AgentModeDropdown: Failed to load modes: \(error.localizedDescription)")
+ }
+ }
+ }
+}
diff --git a/Core/Sources/HostApp/ToolsSettings/AutoApprove/AutoApprovalDisableView.swift b/Core/Sources/HostApp/ToolsSettings/AutoApprove/AutoApprovalDisableView.swift
new file mode 100644
index 00000000..7a74b12f
--- /dev/null
+++ b/Core/Sources/HostApp/ToolsSettings/AutoApprove/AutoApprovalDisableView.swift
@@ -0,0 +1,25 @@
+import Client
+import Foundation
+import Logger
+import SharedUIComponents
+import SwiftUI
+
+struct AutoApprovalDisableView: View {
+ var body: some View {
+ GroupBox {
+ HStack(alignment: .top, spacing: 8) {
+ Image(systemName: "info.circle.fill")
+ .font(.body)
+ .foregroundColor(.gray)
+ Text(
+ "Auto approval is disabled by your organization's policy. To enable it, please contact your administrator. [Get More Info about Copilot policies](https://docs.github.com/en/copilot/how-tos/administer-copilot/manage-for-organization/manage-policies)"
+ )
+ }
+ }
+ .groupBoxStyle(
+ CardGroupBoxStyle(
+ backgroundColor: Color(nsColor: .textBackgroundColor)
+ )
+ )
+ }
+}
diff --git a/Core/Sources/HostApp/ToolsSettings/AutoApprove/AutoApproveContainerView.swift b/Core/Sources/HostApp/ToolsSettings/AutoApprove/AutoApproveContainerView.swift
new file mode 100644
index 00000000..0c75e42b
--- /dev/null
+++ b/Core/Sources/HostApp/ToolsSettings/AutoApprove/AutoApproveContainerView.swift
@@ -0,0 +1,32 @@
+// AutoApproveContainerView.swift
+// Container view for the auto-approve feature in Tools Settings
+// Created: 2026-01-08
+//
+// This view wraps EditsAutoApproveView in a VStack for layout.
+
+import AppKit
+import Logger
+import SharedUIComponents
+import SwiftUI
+
+struct AutoApproveContainerView: View {
+ @ObservedObject private var featureFlags = FeatureFlagManager.shared
+ @ObservedObject private var copilotPolicy = CopilotPolicyManager.shared
+
+ private var isAutoApprovalEnabled: Bool {
+ featureFlags.isAgenModeAutoApprovalEnabled && copilotPolicy.isAgentModeAutoApprovalEnabled
+ }
+
+ var body: some View {
+ VStack(spacing: 16) {
+ if isAutoApprovalEnabled {
+ EditsAutoApproveView()
+ TerminalAutoApproveView()
+ MCPAutoApproveView()
+ } else {
+ AutoApprovalDisableView()
+ }
+ }
+ .padding(.bottom, 20)
+ }
+}
diff --git a/Core/Sources/HostApp/ToolsSettings/AutoApprove/EditsAutoApproveView.swift b/Core/Sources/HostApp/ToolsSettings/AutoApprove/EditsAutoApproveView.swift
new file mode 100644
index 00000000..4d9415f6
--- /dev/null
+++ b/Core/Sources/HostApp/ToolsSettings/AutoApprove/EditsAutoApproveView.swift
@@ -0,0 +1,280 @@
+import AppKit
+import Client
+import Logger
+import Preferences
+import SharedUIComponents
+import SwiftUI
+import UserDefaultsObserver
+import ComposableArchitecture
+
+struct EditsAutoApproveView: View {
+ @State private var isExpanded: Bool = true
+ @StateObject private var viewModel = ViewModel()
+ @State private var selection = Set()
+
+ let rowHeight: CGFloat = 28
+
+ private var canRemoveSelection: Bool {
+ guard !selection.isEmpty else { return false }
+ return !viewModel.rules.contains { rule in
+ selection.contains(rule.id) && rule.isDefault
+ }
+ }
+
+ var body: some View {
+ VStack(spacing: 0) {
+ DisclosureSettingsRow(
+ isExpanded: $isExpanded,
+ accessibilityLabel: { $0 ? "Collapse edits auto-approve section" : "Expand edits auto-approve section" },
+ title: { Text("Edits Auto-Approve").font(.headline) },
+ subtitle: { Text("Controls whether file edits generated by Copilot are approved automatically. Set to **true** to auto-approve edits to matching files; set to **false** to always require explicit approval.") }
+ )
+
+ if isExpanded {
+ VStack(alignment: .leading, spacing: 0) {
+ Divider()
+
+ rulesTable
+
+ Divider()
+
+ toolbar
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(QuaternarySystemFillColor.opacity(0.75))
+ .transition(.opacity.combined(with: .scale(scale: 1, anchor: .top)))
+ }
+ }
+ .settingsContainerStyle(isExpanded: isExpanded)
+ .onAppear {
+ viewModel.loadRules()
+ }
+ }
+
+ @ViewBuilder
+ private var rulesTable: some View {
+ if #available(macOS 13.5, *) {
+ Table(viewModel.rules, selection: $selection) {
+ TableColumn(Text("Pattern").bold()) { rule in
+ if rule.isDefault {
+ Text(rule.pattern).help(rule.pattern)
+ } else {
+ EditableText("Pattern", text: rule.pattern) { newText in
+ viewModel.updateRule(id: rule.id, pattern: newText)
+ }
+ .help("Click to edit pattern")
+ }
+ }
+ TableColumn("Description") { rule in
+ if rule.isDefault {
+ Text(rule.description).help(rule.description)
+ } else {
+ EditableText("Description", text: rule.description) { newText in
+ viewModel.updateRule(id: rule.id, description: newText)
+ }
+ .help(rule.description)
+ }
+ }
+ TableColumn("Type") { rule in
+ Text(rule.isDefault ? "Default" : "Custom")
+ .foregroundStyle(.secondary)
+ }
+ TableColumn("Auto-Approve") { rule in
+ Toggle(rule.isDefault ? "Default to false" : "", isOn: Binding(
+ get: { rule.autoApprove },
+ set: { viewModel.updateRule(id: rule.id, autoApprove: $0) }
+ ))
+ .disabled(rule.isDefault)
+ }
+ }
+ .frame(height: CGFloat(max(viewModel.rules.count, 1)) * rowHeight + 40)
+ .padding(.horizontal, 20)
+ .transparentBackground()
+ }
+ }
+
+ @ViewBuilder
+ private var toolbar: some View {
+ HStack(spacing: 8) {
+ Button(action: { viewModel.addRule() }) {
+ Image(systemName: "plus")
+ }
+ .foregroundColor(.primary)
+ .buttonStyle(.borderless)
+ .padding(.leading, 8)
+
+ Divider()
+
+ Group {
+ if canRemoveSelection {
+ Button(action: {
+ viewModel.removeRules(ids: selection)
+ selection.removeAll()
+ }) {
+ Image(systemName: "minus")
+ }
+ .buttonStyle(.borderless)
+ } else {
+ Image(systemName: "minus")
+ }
+ }
+ .foregroundColor(
+ canRemoveSelection ? .primary : Color(
+ nsColor: .quaternaryLabelColor
+ )
+ )
+ .help("Remove selected rules")
+
+ Spacer()
+ }
+ .frame(height: 24)
+ .background(TertiarySystemFillColor)
+ }
+}
+
+extension EditsAutoApproveView {
+ final class ViewModel: ObservableObject {
+ @Dependency(\.toast) var toast
+
+ struct Rule: Identifiable {
+ var id = UUID()
+ var pattern: String
+ var description: String
+ var autoApprove: Bool
+ var isDefault: Bool
+ }
+
+ @Published var rules: [Rule] = []
+ private let defaults = UserDefaults.autoApproval
+ private var observer = UserDefaultsObserver(
+ object: UserDefaults.autoApproval,
+ forKeyPaths: [UserDefaultPreferenceKeys().sensitiveFilesGlobalApprovals.key],
+ context: nil
+ )
+
+ private let defaultRules: [Rule] = [
+ Rule(pattern: "**/.github/instructions/*", description: "Github instructions files", autoApprove: false, isDefault: true),
+ Rule(pattern: "**/github-copilot/**/*", description: "Github Copilot settings and token files", autoApprove: false, isDefault: true),
+ ]
+
+ init() {
+ observer.onChange = { [weak self] in
+ DispatchQueue.main.async {
+ self?.loadRules()
+ }
+ }
+ }
+
+ func loadRules() {
+ var loadedRules: [Rule] = []
+
+ // Load from UserDefaults
+ let state = defaults.value(for: \.sensitiveFilesGlobalApprovals)
+ let savedRules = state.rules
+
+ func findExistingID(pattern: String) -> UUID {
+ return rules.first(where: { $0.pattern == pattern })?.id ?? UUID()
+ }
+
+ // Add default rules first
+ for defaultRule in defaultRules {
+ var rule = defaultRule
+ // If it exists in persisted config, override properties that can be changed (autoApprove)
+ // We keep the default description unless we want to allow overriding it.
+ if let savedRule = savedRules[defaultRule.pattern] {
+ rule.autoApprove = savedRule.autoApprove
+ if !savedRule.description.isEmpty {
+ rule.description = savedRule.description
+ }
+ }
+ rule.id = findExistingID(pattern: rule.pattern)
+ loadedRules.append(rule)
+ }
+
+ // Add custom rules
+ for (patternKey, value) in savedRules {
+ // Skip if it's a default rule
+ if defaultRules.contains(where: { $0.pattern == patternKey }) { continue }
+
+ let id = findExistingID(pattern: patternKey)
+
+ loadedRules.append(Rule(id: id, pattern: patternKey, description: value.description, autoApprove: value.autoApprove, isDefault: false))
+ }
+
+ rules = loadedRules.sorted {
+ if $0.isDefault != $1.isDefault {
+ return $0.isDefault // Defaults first
+ }
+ return $0.pattern < $1.pattern
+ }
+ }
+
+ func addRule() {
+ var counter = 0
+ var newPattern = "New Pattern"
+ while rules.contains(where: { $0.pattern == newPattern }) {
+ counter += 1
+ newPattern = "New Pattern \(counter)"
+ }
+ rules.append(Rule(pattern: newPattern, description: "Description", autoApprove: false, isDefault: false))
+ saveRules()
+ }
+
+ func removeRules(ids: Set) {
+ rules.removeAll { ids.contains($0.id) && !$0.isDefault }
+ saveRules()
+ }
+
+ @discardableResult
+ func updateRule(id: UUID, pattern: String? = nil, description: String? = nil, autoApprove: Bool? = nil) -> Bool {
+ guard let index = rules.firstIndex(where: { $0.id == id }) else { return false }
+
+ if let pattern {
+ var newPattern = pattern.filter { !$0.isNewline }
+ newPattern = newPattern.trimmingCharacters(in: .whitespacesAndNewlines)
+
+ if !rules.contains(where: { $0.id != id && $0.pattern == newPattern }) {
+ rules[index].pattern = newPattern
+ } else {
+ toast("Duplicate patterns are not allowed. Please ensure each rule has a unique pattern.", .warning)
+ return false
+ }
+ }
+ if let description { rules[index].description = description }
+ if let autoApprove { rules[index].autoApprove = autoApprove }
+
+ saveRules()
+ return true
+ }
+
+ func saveRules() {
+ // Check for duplicate patterns
+ let patterns = rules.map(\.pattern)
+ let uniquePatterns = Set(patterns)
+ if patterns.count != uniquePatterns.count {
+ return
+ }
+
+ var state = defaults.value(for: \.sensitiveFilesGlobalApprovals)
+ var newRules: [String: SensitiveFileRule] = [:]
+
+ for rule in rules {
+ newRules[rule.pattern] = SensitiveFileRule(description: rule.description, autoApprove: rule.autoApprove)
+ }
+
+ state.rules = newRules
+ defaults.set(state, for: \.sensitiveFilesGlobalApprovals)
+ Task {
+ do {
+ let service = try getService()
+ try await service.postNotification(
+ name: Notification.Name
+ .gitHubCopilotShouldRefreshEditorInformation.rawValue
+ )
+ } catch {
+ toast(error.localizedDescription, .error)
+ }
+ }
+ }
+ }
+}
diff --git a/Core/Sources/HostApp/ToolsSettings/AutoApprove/MCPAutoApproveView.swift b/Core/Sources/HostApp/ToolsSettings/AutoApprove/MCPAutoApproveView.swift
new file mode 100644
index 00000000..962f2630
--- /dev/null
+++ b/Core/Sources/HostApp/ToolsSettings/AutoApprove/MCPAutoApproveView.swift
@@ -0,0 +1,294 @@
+import AppKit
+import Combine
+import Client
+import GitHubCopilotService
+import Logger
+import Preferences
+import SharedUIComponents
+import SwiftUI
+import UserDefaultsObserver
+
+struct MCPAutoApproveView: View {
+ @State private var isExpanded: Bool = true
+ @StateObject private var viewModel = ViewModel()
+
+ var body: some View {
+ VStack(spacing: 0) {
+ DisclosureSettingsRow(
+ isExpanded: $isExpanded,
+ accessibilityLabel: { $0 ? "Collapse MCP auto-approve section" : "Expand MCP auto-approve section" },
+ title: { Text("MCP Auto-Approve").font(.headline) },
+ subtitle: { Text("Controls whether MCP tool calls triggered by Copilot are automatically approved. You can enable MCP auto-approval per server or per tool.") }
+ )
+
+ if isExpanded {
+ Divider()
+ AgentTrustToolAnnotationsSetting()
+ .padding(.horizontal, 26)
+ .background(QuaternarySystemFillColor.opacity(0.75))
+ .transition(.opacity.combined(with: .scale(scale: 1, anchor: .top)))
+ Divider()
+ if #available(macOS 14.0, *) {
+ if viewModel.rows.isEmpty {
+ Text(noRunningServersMessage)
+ .foregroundColor(.secondary)
+ .multilineTextAlignment(.center)
+ .environment(\.openURL, OpenURLAction { url in
+ if url.scheme == "action", url.host == "open-mcp-tab" {
+ hostAppStore.send(.setActiveTab(.tools))
+ hostAppStore.send(.setActiveToolsSubTab(.MCP))
+ return .handled
+ }
+ NSWorkspace.openFileInXcode(fileURL: url)
+ return .handled
+ })
+ .frame(maxWidth: .infinity, alignment: .center)
+ .padding()
+ .background(QuaternarySystemFillColor.opacity(0.75))
+ } else {
+ Table(viewModel.rows, children: \.children) {
+ TableColumn(Text("MCP Server").bold()) { row in
+ HStack(alignment: .center, spacing: 4) {
+ if case .runAny = row.type {
+ Image(systemName: "play.rectangle.on.rectangle")
+ .foregroundColor(.secondary)
+ } else if case .tool = row.type {
+ Image(systemName: "play.rectangle.on.rectangle")
+ .opacity(0)
+ .accessibilityHidden(true)
+ }
+
+ Text(row.title)
+ if case .tool = row.type {
+ Text("without approval")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ }
+ }
+ TableColumn("Auto-Approve") { row in
+ if case .server = row.type {
+ EmptyView()
+ } else {
+ Toggle(isOn: binding(for: row)) {
+ Text("")
+ }
+ .toggleStyle(CheckboxToggleStyle())
+ .labelsHidden()
+ }
+ }
+ .width(100)
+ }
+ .frame(minHeight: 300, maxHeight: .infinity)
+ .transparentBackground()
+ .padding(.horizontal, 10)
+ .background(QuaternarySystemFillColor.opacity(0.75))
+ .transition(.opacity.combined(with: .scale(scale: 1, anchor: .top)))
+ }
+ }
+ }
+ }
+ .settingsContainerStyle(isExpanded: isExpanded)
+ }
+
+ private var noRunningServersMessage: AttributedString {
+ var text = AttributedString(localized: "No running MCP servers found. Please verify the status in the MCP section or add configs in mcp.json.")
+ if let range = text.range(of: "mcp.json") {
+ text[range].link = URL(fileURLWithPath: mcpConfigFilePath)
+ }
+ if let range = text.range(of: "MCP section") {
+ text[range].link = URL(string: "action://open-mcp-tab")
+ }
+ return text
+ }
+
+ private func binding(for row: RowItem) -> Binding {
+ Binding(
+ get: {
+ switch row.type {
+ case .server(let name):
+ return viewModel.isServerAllowed(name)
+ case .runAny(let serverName):
+ return viewModel.isServerAllowed(serverName)
+ case .tool(let serverName, let toolName):
+ return viewModel.isToolAllowed(serverName: serverName, toolName: toolName)
+ }
+ },
+ set: { newValue in
+ switch row.type {
+ case .server(let name), .runAny(let name):
+ viewModel.setServerAllowed(name, allowed: newValue)
+ case .tool(let serverName, let toolName):
+ viewModel.setToolAllowed(serverName, toolName: toolName, allowed: newValue)
+ }
+ }
+ )
+ }
+}
+
+struct RowItem: Identifiable {
+ let id: String
+ let title: String
+ let type: ItemType
+ var children: [RowItem]?
+}
+
+enum ItemType: Equatable {
+ case server(String)
+ case runAny(serverName: String)
+ case tool(serverName: String, toolName: String)
+}
+
+extension MCPAutoApproveView {
+ @MainActor
+ class ViewModel: ObservableObject {
+ @Published var rows: [RowItem] = []
+ private var serverTools: [MCPServerToolsCollection] = []
+ private var approvals: AutoApprovedMCPServers = AutoApprovedMCPServers()
+ private var cancellables = Set()
+
+ private let mcpToolManager = CopilotMCPToolManagerObservable.shared
+ private var observer: UserDefaultsObserver?
+
+ @Environment(\.toast) private var toast
+
+ init() {
+ // Observe tools availability
+ mcpToolManager.$availableMCPServerTools
+ .sink { [weak self] tools in
+ guard let self = self else { return }
+ self.serverTools = tools
+ self.rebuildRows()
+ }
+ .store(in: &cancellables)
+
+ // Observe user defaults
+ observer = UserDefaultsObserver(
+ object: UserDefaults.autoApproval,
+ forKeyPaths: [UserDefaultPreferenceKeys().mcpServersGlobalApprovals.key],
+ context: nil
+ )
+
+ observer?.onChange = { [weak self] in
+ guard let self = self else { return }
+ DispatchQueue.main.async {
+ self.loadApprovals()
+ }
+ }
+
+ // Initial load so the table reflects saved state on first appearance.
+ loadApprovals()
+ }
+
+ private func rebuildRows() {
+ rows = serverTools
+ .filter { $0.status == .running }
+ .map { server in
+ let isAllowed = approvals.servers[server.name]?.isServerAllowed ?? false
+ var children: [RowItem] = []
+
+ // "Run any tool" row
+ children.append(RowItem(
+ id: "run-any-\(server.name)",
+ title: "Run any tool without approval",
+ type: .runAny(serverName: server.name),
+ children: nil
+ ))
+
+ // Tools rows (only if not allowed globally)
+ if !isAllowed {
+ let toolRows = server.tools.map { tool in
+ RowItem(
+ id: "tool-\(server.name)-\(tool.name)",
+ title: tool.name,
+ type: .tool(serverName: server.name, toolName: tool.name),
+ children: nil
+ )
+ }
+ children.append(contentsOf: toolRows)
+ }
+
+ return RowItem(
+ id: "server-\(server.name)",
+ title: server.name,
+ type: .server(server.name),
+ children: children
+ )
+ }
+ }
+
+ private func loadApprovals() {
+ self.approvals = UserDefaults.autoApproval.value(for: \.mcpServersGlobalApprovals)
+ rebuildRows()
+ }
+
+ func isServerAllowed(_ serverName: String) -> Bool {
+ return approvals.servers[serverName]?.isServerAllowed ?? false
+ }
+
+ func isToolAllowed(serverName: String, toolName: String) -> Bool {
+ return approvals.servers[serverName]?.allowedTools.contains(toolName) ?? false
+ }
+
+ func setServerAllowed(_ serverName: String, allowed: Bool) {
+ var currentApprovals = UserDefaults.autoApproval.value(for: \.mcpServersGlobalApprovals)
+ var serverState = currentApprovals.servers[serverName] ?? MCPServerApprovalState()
+
+ serverState.isServerAllowed = allowed
+ currentApprovals.servers[serverName] = serverState
+
+ save(currentApprovals)
+ // Rebuild happens via observer
+ }
+
+ func setToolAllowed(_ serverName: String, toolName: String, allowed: Bool) {
+ var currentApprovals = UserDefaults.autoApproval.value(for: \.mcpServersGlobalApprovals)
+ var serverState = currentApprovals.servers[serverName] ?? MCPServerApprovalState()
+
+ if allowed {
+ serverState.allowedTools.insert(toolName)
+ } else {
+ serverState.allowedTools.remove(toolName)
+ }
+ currentApprovals.servers[serverName] = serverState
+
+ save(currentApprovals)
+ }
+
+ private func save(_ approvals: AutoApprovedMCPServers) {
+ UserDefaults.autoApproval.set(approvals, for: \.mcpServersGlobalApprovals)
+ notifyChange()
+ }
+
+ private func notifyChange() {
+ Task {
+ do {
+ let service = try getService()
+ try await service.postNotification(
+ name: Notification.Name
+ .gitHubCopilotShouldRefreshEditorInformation.rawValue
+ )
+ } catch {
+ toast(error.localizedDescription, .error)
+ }
+ }
+ }
+ }
+}
+
+struct AgentTrustToolAnnotationsSetting: View {
+ @AppStorage(\.trustToolAnnotations) var trustToolAnnotations
+
+ var body: some View {
+ SettingsToggle(
+ title: "Trust MCP Tool Annotations",
+ subtitle: "If enabled, Copilot will use tool annotations to decide whether to automatically approve readonly MCP tool calls.",
+ isOn: $trustToolAnnotations
+ )
+ .onChange(of: trustToolAnnotations) { _ in
+ DistributedNotificationCenter
+ .default()
+ .post(name: .githubCopilotAgentTrustToolAnnotationsDidChange, object: nil)
+ }
+ }
+}
diff --git a/Core/Sources/HostApp/ToolsSettings/AutoApprove/TerminalAutoApproveView.swift b/Core/Sources/HostApp/ToolsSettings/AutoApprove/TerminalAutoApproveView.swift
new file mode 100644
index 00000000..bec514f8
--- /dev/null
+++ b/Core/Sources/HostApp/ToolsSettings/AutoApprove/TerminalAutoApproveView.swift
@@ -0,0 +1,233 @@
+import AppKit
+import Client
+import Logger
+import Preferences
+import SharedUIComponents
+import SwiftUI
+import UserDefaultsObserver
+import ComposableArchitecture
+
+struct TerminalAutoApproveView: View {
+ @State private var isExpanded: Bool = true
+ @StateObject private var viewModel = ViewModel()
+ @State private var selection = Set()
+
+ let rowHeight: CGFloat = 28
+
+ private var canRemoveSelection: Bool {
+ !selection.isEmpty
+ }
+
+ var body: some View {
+ VStack(spacing: 0) {
+ DisclosureSettingsRow(
+ isExpanded: $isExpanded,
+ accessibilityLabel: { $0 ? "Collapse terminal auto-approve section" : "Expand terminal auto-approve section" },
+ title: { Text("Terminal Auto-Approve").font(.headline) },
+ subtitle: {
+ Text(
+ "Controls whether chat-initiated terminal commands are automatically approved. Set to **true** to auto-approve matching commands; set to **false** to always require explicit approval."
+ )
+ }
+ )
+
+ if isExpanded {
+ VStack(alignment: .leading, spacing: 0) {
+ Divider()
+
+ rulesTable
+
+ Divider()
+
+ toolbar
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(QuaternarySystemFillColor.opacity(0.75))
+ .transition(.opacity.combined(with: .scale(scale: 1, anchor: .top)))
+ }
+ }
+ .settingsContainerStyle(isExpanded: isExpanded)
+ .onAppear {
+ viewModel.loadRules()
+ }
+ }
+
+ @ViewBuilder
+ private var rulesTable: some View {
+ Table(viewModel.rules, selection: $selection) {
+ TableColumn("Command") { rule in
+ EditableText("Command", text: rule.command) { newText in
+ viewModel.updateRule(id: rule.id, command: newText)
+ }
+ .help("Click to edit command")
+ }
+ TableColumn("Auto-Approve") { rule in
+ Toggle("", isOn: Binding(
+ get: { rule.autoApprove },
+ set: { viewModel.updateRule(id: rule.id, autoApprove: $0) }
+ ))
+ }
+ }
+ .frame(height: CGFloat(max(viewModel.rules.count, 1)) * rowHeight + 42)
+ .padding(.horizontal, 20)
+ .transparentBackground()
+ }
+
+ @ViewBuilder
+ private var toolbar: some View {
+ HStack(spacing: 8) {
+ Button(action: { viewModel.addRule() }) {
+ Image(systemName: "plus")
+ }
+ .foregroundColor(.primary)
+ .buttonStyle(.borderless)
+ .padding(.leading, 8)
+
+ Divider()
+
+ Group {
+ if canRemoveSelection {
+ Button(action: {
+ viewModel.removeRules(ids: selection)
+ selection.removeAll()
+ }) {
+ Image(systemName: "minus")
+ }
+ .buttonStyle(.borderless)
+ } else {
+ Image(systemName: "minus")
+ }
+ }
+ .foregroundColor(
+ canRemoveSelection ? .primary : Color(
+ nsColor: .quaternaryLabelColor
+ )
+ )
+ .help("Remove selected rules")
+
+ Spacer()
+ }
+ .frame(height: 24)
+ .background(TertiarySystemFillColor)
+ }
+}
+
+extension TerminalAutoApproveView {
+ final class ViewModel: ObservableObject {
+ @Dependency(\.toast) var toast
+
+ struct Rule: Identifiable {
+ var id = UUID()
+ var command: String
+ var autoApprove: Bool
+ }
+
+ @Published var rules: [Rule] = []
+
+ private let defaults = UserDefaults.autoApproval
+ private var observer = UserDefaultsObserver(
+ object: UserDefaults.autoApproval,
+ forKeyPaths: [UserDefaultPreferenceKeys().terminalCommandsGlobalApprovals.key],
+ context: nil
+ )
+
+ init() {
+ observer.onChange = { [weak self] in
+ DispatchQueue.main.async {
+ self?.loadRules()
+ }
+ }
+ }
+
+ func loadRules() {
+ let state = defaults.value(for: \.terminalCommandsGlobalApprovals)
+ let savedRules = state.commands
+
+ func findExistingID(command: String) -> UUID {
+ rules.first(where: { $0.command == command })?.id ?? UUID()
+ }
+
+ var loadedRules: [Rule] = []
+ for (commandKey, autoApprove) in savedRules {
+ loadedRules.append(
+ Rule(id: findExistingID(command: commandKey), command: commandKey, autoApprove: autoApprove)
+ )
+ }
+
+ rules = loadedRules.sorted { $0.command.localizedCaseInsensitiveCompare($1.command) == .orderedAscending }
+ }
+
+ func addRule() {
+ var counter = 0
+ var newCommand = "New Command"
+ while rules.contains(where: { $0.command == newCommand }) {
+ counter += 1
+ newCommand = "New Command \(counter)"
+ }
+ rules.append(Rule(command: newCommand, autoApprove: false))
+ saveRules()
+ }
+
+ func removeRules(ids: Set) {
+ rules.removeAll { ids.contains($0.id) }
+ saveRules()
+ }
+
+ @discardableResult
+ func updateRule(id: UUID, command: String? = nil, autoApprove: Bool? = nil) -> Bool {
+ guard let index = rules.firstIndex(where: { $0.id == id }) else { return false }
+
+ if let command {
+ var newCommand = command.filter { !$0.isNewline }
+ newCommand = newCommand.trimmingCharacters(in: .whitespacesAndNewlines)
+
+ guard !newCommand.isEmpty else {
+ toast("Command cannot be empty.", .warning)
+ return false
+ }
+
+ if !rules.contains(where: { $0.id != id && $0.command == newCommand }) {
+ rules[index].command = newCommand
+ } else {
+ toast("Duplicate commands are not allowed. Please ensure each rule has a unique command.", .warning)
+ return false
+ }
+ }
+ if let autoApprove { rules[index].autoApprove = autoApprove }
+
+ saveRules()
+ return true
+ }
+
+ func saveRules() {
+ let commands = rules.map(\.command)
+ let uniqueCommands = Set(commands)
+ if commands.count != uniqueCommands.count {
+ return
+ }
+ if commands.contains(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) {
+ toast("Command cannot be empty.", .warning)
+ return
+ }
+
+ var state = defaults.value(for: \.terminalCommandsGlobalApprovals)
+ var newRules: [String: Bool] = [:]
+ for rule in rules {
+ newRules[rule.command] = rule.autoApprove
+ }
+ state.commands = newRules
+ defaults.set(state, for: \.terminalCommandsGlobalApprovals)
+
+ Task {
+ do {
+ let service = try getService()
+ try await service.postNotification(
+ name: Notification.Name.githubCopilotAgentAutoApprovalDidChange.rawValue
+ )
+ } catch {
+ toast(error.localizedDescription, .error)
+ }
+ }
+ }
+ }
+}
diff --git a/Core/Sources/HostApp/ToolsSettings/BuiltInToolsListView.swift b/Core/Sources/HostApp/ToolsSettings/BuiltInToolsListView.swift
index 021ea437..6cdd7a0c 100644
--- a/Core/Sources/HostApp/ToolsSettings/BuiltInToolsListView.swift
+++ b/Core/Sources/HostApp/ToolsSettings/BuiltInToolsListView.swift
@@ -5,12 +5,16 @@ import GitHubCopilotService
import Logger
import Persist
import SwiftUI
+import SharedUIComponents
struct BuiltInToolsListView: View {
@ObservedObject private var builtInToolManager = CopilotBuiltInToolManagerObservable.shared
@State private var isSearchBarVisible: Bool = false
@State private var searchText: String = ""
@State private var toolEnabledStates: [String: Bool] = [:]
+ @State private var modes: [ConversationMode] = []
+ @Binding var selectedMode: ConversationMode
+ let isCustomAgentEnabled: Bool
var body: some View {
VStack(alignment: .leading, spacing: 8) {
@@ -21,23 +25,51 @@ struct BuiltInToolsListView: View {
}
.onAppear {
initializeToolStates()
+ // Refresh client tools to get any late-arriving server tools
+ Task {
+ do {
+ let service = try getService()
+ _ = try await service.refreshClientTools()
+ } catch {
+ Logger.client.error("Failed to refresh client tools: \(error)")
+ }
+ }
}
.onChange(of: builtInToolManager.availableLanguageModelTools) { _ in
initializeToolStates()
}
+ .onChange(of: selectedMode) { _ in
+ toolEnabledStates = [:] // Clear state immediately
+ initializeToolStates()
+ }
+ .onReceive(DistributedNotificationCenter.default().publisher(for: .gitHubCopilotCustomAgentToolsDidChange)) { _ in
+ Logger.client.info("Custom agent tools change notification received in BuiltInToolsListView")
+ if !selectedMode.isDefaultAgent {
+ Task {
+ await reloadModesAndUpdateStates()
+ }
+ }
+ }
}
// MARK: - Header View
private var headerView: some View {
- HStack(alignment: .center) {
- Text("Built-In Tools").fontWeight(.bold)
- Spacer()
- SearchBar(isVisible: $isSearchBarVisible, text: $searchText)
+ VStack(alignment: .leading, spacing: 8) {
+ HStack(alignment: .center) {
+ Text("Built-In Tools").fontWeight(.bold)
+ if isCustomAgentEnabled {
+ AgentModeDropdown(modes: $modes, selectedMode: $selectedMode)
+ }
+ Spacer()
+ CollapsibleSearchField(searchText: $searchText, isExpanded: $isSearchBarVisible)
+ }
+ .clipped()
+
+ AgentModeDescriptionView(selectedMode: selectedMode, isLoadingMode: false)
}
- .clipped()
}
-
+
// MARK: - Content View
private var contentView: some View {
@@ -61,6 +93,7 @@ struct BuiltInToolsListView: View {
toolStatus: tool.status,
isServerEnabled: true,
isToolEnabled: toolBindingFor(tool),
+ isInteractionAllowed: isInteractionAllowed(),
onToolToggleChanged: { isEnabled in
handleToolToggleChange(tool: tool, isEnabled: isEnabled)
}
@@ -72,21 +105,19 @@ struct BuiltInToolsListView: View {
// MARK: - Helper Methods
private func initializeToolStates() {
+ // When mode changes, recalculate everything from scratch
var map: [String: Bool] = [:]
for tool in builtInToolManager.availableLanguageModelTools {
- // Preserve existing state if already toggled locally
- if let existing = toolEnabledStates[tool.name] {
- map[tool.name] = existing
- } else {
- map[tool.name] = (tool.status == .enabled)
- }
+ map[tool.name] = isToolEnabledInMode(tool)
}
toolEnabledStates = map
}
private func toolBindingFor(_ tool: LanguageModelTool) -> Binding {
Binding(
- get: { toolEnabledStates[tool.name] ?? (tool.status == .enabled) },
+ get: {
+ toolEnabledStates[tool.name] ?? isToolEnabledInMode(tool)
+ },
set: { newValue in
toolEnabledStates[tool.name] = newValue
}
@@ -105,7 +136,6 @@ struct BuiltInToolsListView: View {
}
private func handleToolToggleChange(tool: LanguageModelTool, isEnabled: Bool) {
- // Optimistically update local state already done in binding.
let toolUpdate = ToolStatusUpdate(name: tool.name, status: isEnabled ? .enabled : .disabled)
updateToolStatus([toolUpdate])
}
@@ -114,17 +144,73 @@ struct BuiltInToolsListView: View {
Task {
do {
let service = try getService()
- let updatedTools = try await service.updateToolsStatus(toolUpdates)
- if updatedTools == nil {
- Logger.client.error("Failed to update built-in tool status: No updated tools returned")
+
+ if !selectedMode.isDefaultAgent {
+ let chatMode = selectedMode.kind
+ let customChatModeId = selectedMode.isBuiltIn == false ? selectedMode.id : nil
+ let workspaceFolders = await getWorkspaceFolders()
+
+ let updatedTools = try await service
+ .updateToolsStatus(
+ toolUpdates,
+ chatAgentMode: chatMode,
+ customChatModeId: customChatModeId,
+ workspaceFolders: workspaceFolders
+ )
+
+ if updatedTools == nil {
+ Logger.client.error("Failed to update built-in tool status: No updated tools returned")
+ }
+
+ await reloadModesAndUpdateStates()
+ } else {
+ let updatedTools = try await service.updateToolsStatus(toolUpdates)
+ if updatedTools == nil {
+ Logger.client.error("Failed to update built-in tool status: No updated tools returned")
+ }
}
- // CopilotLanguageModelToolManager will broadcast changes; our local
- // toolEnabledStates keep rows visible even if disabled.
} catch {
Logger.client.error("Failed to update built-in tool status: \(error.localizedDescription)")
}
}
}
+
+ @MainActor
+ private func reloadModesAndUpdateStates() async {
+ do {
+ let service = try getService()
+ let workspaceFolders = await getWorkspaceFolders()
+ if let fetchedModes = try await service.getModes(workspaceFolders: workspaceFolders) {
+ modes = fetchedModes.filter { $0.kind == .Agent }
+
+ if let updatedMode = modes.first(where: { $0.id == selectedMode.id }) {
+ selectedMode = updatedMode
+
+ for tool in builtInToolManager.availableLanguageModelTools {
+ if let customTools = updatedMode.customTools {
+ toolEnabledStates[tool.name] = customTools.contains(tool.name)
+ } else {
+ toolEnabledStates[tool.name] = false
+ }
+ }
+ }
+ }
+ } catch {
+ Logger.client.error("Failed to reload modes: \(error.localizedDescription)")
+ }
+ }
+
+ private func isToolEnabledInMode(_ tool: LanguageModelTool) -> Bool {
+ return AgentModeToolHelpers.isToolEnabledInMode(
+ configurationKey: tool.name,
+ currentStatus: tool.status,
+ selectedMode: selectedMode
+ )
+ }
+
+ private func isInteractionAllowed() -> Bool {
+ return AgentModeToolHelpers.isInteractionAllowed(selectedMode: selectedMode)
+ }
}
/// Empty state view when no tools are available
diff --git a/Core/Sources/HostApp/ToolsSettings/CopilotMCPToolManagerObservable.swift b/Core/Sources/HostApp/ToolsSettings/CopilotMCPToolManagerObservable.swift
index d493b8be..8c3444d2 100644
--- a/Core/Sources/HostApp/ToolsSettings/CopilotMCPToolManagerObservable.swift
+++ b/Core/Sources/HostApp/ToolsSettings/CopilotMCPToolManagerObservable.swift
@@ -17,6 +17,7 @@ class CopilotMCPToolManagerObservable: ObservableObject {
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
guard let self = self else { return }
+ Logger.client.info("MCP tools change notification received")
Task {
await self.refreshMCPServerTools()
}
@@ -31,6 +32,7 @@ class CopilotMCPToolManagerObservable: ObservableObject {
@MainActor
private func refreshMCPServerTools() async {
+ Logger.client.info("Refreshing MCP server tools...")
do {
let service = try getService()
let mcpTools = try await service.getAvailableMCPServerToolsCollections()
@@ -43,11 +45,14 @@ class CopilotMCPToolManagerObservable: ObservableObject {
private func refreshTools(tools: [MCPServerToolsCollection]?) {
guard let tools = tools else {
// nil means the tools data is ready, and skip it first.
+ Logger.client.info("MCP tools data not ready yet, skipping refresh")
return
}
- AppState.shared.cleanupMCPToolsStatus(availableTools: tools)
- AppState.shared.createMCPToolsStatus(tools)
+ let totalToolsCount = tools.reduce(0) { $0 + $1.tools.count }
+ let serverNames = tools.map { $0.name }.joined(separator: ", ")
+ Logger.client.info("Refreshed MCP tools - Servers: \(tools.count), Total tools: \(totalToolsCount), Server names: [\(serverNames)]")
+
self.availableMCPServerTools = tools
}
}
diff --git a/Core/Sources/HostApp/ToolsSettings/MCPAppState.swift b/Core/Sources/HostApp/ToolsSettings/MCPAppState.swift
deleted file mode 100644
index f6d16d98..00000000
--- a/Core/Sources/HostApp/ToolsSettings/MCPAppState.swift
+++ /dev/null
@@ -1,116 +0,0 @@
-import Persist
-import GitHubCopilotService
-import Foundation
-
-public let MCP_TOOLS_STATUS = "mcpToolsStatus"
-
-extension AppState {
- public func getMCPToolsStatus() -> [UpdateMCPToolsStatusServerCollection]? {
- guard let savedJSON = get(key: MCP_TOOLS_STATUS),
- let data = try? JSONEncoder().encode(savedJSON),
- let savedStatus = try? JSONDecoder().decode([UpdateMCPToolsStatusServerCollection].self, from: data) else {
- return nil
- }
- return savedStatus
- }
-
- public func updateMCPToolsStatus(_ servers: [UpdateMCPToolsStatusServerCollection]) {
- var existingServers = getMCPToolsStatus() ?? []
-
- // Update or add servers
- for newServer in servers {
- if let existingIndex = existingServers.firstIndex(where: { $0.name == newServer.name }) {
- // Update existing server
- let updatedTools = mergeTools(original: existingServers[existingIndex].tools, new: newServer.tools)
- existingServers[existingIndex].tools = updatedTools
- } else {
- // Add new server
- existingServers.append(newServer)
- }
- }
-
- update(key: MCP_TOOLS_STATUS, value: existingServers)
- }
-
- private func mergeTools(original: [UpdatedMCPToolsStatus], new: [UpdatedMCPToolsStatus]) -> [UpdatedMCPToolsStatus] {
- var result = original
-
- for newTool in new {
- if let index = result.firstIndex(where: { $0.name == newTool.name }) {
- result[index].status = newTool.status
- } else {
- result.append(newTool)
- }
- }
-
- return result
- }
-
- public func createMCPToolsStatus(_ serverCollections: [MCPServerToolsCollection]) {
- var existingServers = getMCPToolsStatus() ?? []
- var serversChanged = false
-
- for serverCollection in serverCollections {
- // Find or create a server entry
- let serverIndex = existingServers.firstIndex(where: { $0.name == serverCollection.name })
- var toolsToUpdate: [UpdatedMCPToolsStatus]
-
- if let index = serverIndex {
- toolsToUpdate = existingServers[index].tools
- } else {
- toolsToUpdate = []
- serversChanged = true
- }
-
- // Add new tools with default enabled status
- let existingToolNames = Set(toolsToUpdate.map { $0.name })
- let newTools = serverCollection.tools
- .filter { !existingToolNames.contains($0.name) }
- .map { UpdatedMCPToolsStatus(name: $0.name, status: .enabled) }
-
- if !newTools.isEmpty {
- serversChanged = true
- toolsToUpdate.append(contentsOf: newTools)
- }
-
- // Update or add the server
- if let index = serverIndex {
- existingServers[index].tools = toolsToUpdate
- } else {
- existingServers.append(UpdateMCPToolsStatusServerCollection(
- name: serverCollection.name,
- tools: toolsToUpdate
- ))
- }
- }
-
- // Only update storage if changes were made
- if serversChanged {
- update(key: MCP_TOOLS_STATUS, value: existingServers)
- }
- }
-
- public func cleanupMCPToolsStatus(availableTools: [MCPServerToolsCollection]) {
- guard var existingServers = getMCPToolsStatus() else { return }
-
- // Get all available server names and their respective tool names
- let availableServerMap = Dictionary(
- uniqueKeysWithValues: availableTools.map { collection in
- (collection.name, Set(collection.tools.map { $0.name }))
- }
- )
-
- // Remove servers that don't exist in available tools
- existingServers.removeAll { !availableServerMap.keys.contains($0.name) }
-
- // For each remaining server, remove tools that don't exist in available tools
- for i in 0..) {
- _isMCPFFEnabled = isMCPFFEnabled
+ public init(isMCPFFEnabled: Bool) {
+ self.isMCPFFEnabled = isMCPFFEnabled
}
var body: some View {
@@ -35,11 +35,11 @@ struct MCPIntroView: View {
}
#Preview {
- MCPIntroView(isMCPFFEnabled: .constant(true))
+ MCPIntroView(isMCPFFEnabled: true)
.frame(width: 800)
}
#Preview {
- MCPIntroView(isMCPFFEnabled: .constant(false))
+ MCPIntroView(isMCPFFEnabled: false)
.frame(width: 800)
}
diff --git a/Core/Sources/HostApp/ToolsSettings/MCPManualInstallView.swift b/Core/Sources/HostApp/ToolsSettings/MCPManualInstallView.swift
index 36334622..6909b851 100644
--- a/Core/Sources/HostApp/ToolsSettings/MCPManualInstallView.swift
+++ b/Core/Sources/HostApp/ToolsSettings/MCPManualInstallView.swift
@@ -10,8 +10,8 @@ struct MCPManualInstallView: View {
VStack(spacing: 0) {
DisclosureSettingsRow(
isExpanded: $isExpanded,
- accessibilityLabel: { $0 ? "Collapse manual install section" : "Expand manual install section" },
- title: { Text("Manual Install").font(.headline) },
+ accessibilityLabel: { $0 ? "Collapse MCP configuration section" : "Expand MCP configuration section" },
+ title: { Text("MCP Configuration").font(.headline) },
subtitle: { Text("Add MCP Servers to power AI with tools for files, databases, and external APIs.") },
actions: {
HStack(spacing: 8) {
@@ -55,7 +55,6 @@ struct MCPManualInstallView: View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Text("Example Configuration").foregroundColor(.primary.opacity(0.85))
-
CopyButton(
copy: {
NSPasteboard.general.clearContents()
@@ -76,15 +75,7 @@ struct MCPManualInstallView: View {
.transition(.opacity.combined(with: .scale(scale: 1, anchor: .top)))
}
}
- .cornerRadius(12)
- .clipShape(RoundedRectangle(cornerRadius: 12))
- .overlay(
- RoundedRectangle(cornerRadius: 12)
- .inset(by: 0.5)
- .stroke(SecondarySystemFillColor, lineWidth: 1)
- .animation(.easeInOut(duration: 0.3), value: isExpanded)
- )
- .animation(.easeInOut(duration: 0.3), value: isExpanded)
+ .settingsContainerStyle(isExpanded: isExpanded)
}
var exampleConfig: String {
@@ -152,9 +143,3 @@ struct MCPManualInstallView: View {
NSWorkspace.shared.open(url)
}
}
-
-#Preview {
- MCPManualInstallView()
- .padding()
- .frame(width: 900)
-}
diff --git a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryInstallation.swift b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryInstallation.swift
new file mode 100644
index 00000000..52b1264b
--- /dev/null
+++ b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryInstallation.swift
@@ -0,0 +1,481 @@
+import Client
+import Foundation
+import GitHubCopilotService
+import Logger
+import SwiftUI
+
+// MARK: - Installation Option
+
+public struct InstallationOption {
+ public let displayName: String
+ public let description: String
+ public let config: [String: Any]
+ public let isDefault: Bool
+
+ public init(displayName: String, description: String, config: [String: Any], isDefault: Bool = false) {
+ self.displayName = displayName
+ self.description = description
+ self.config = config
+ self.isDefault = isDefault
+ }
+}
+
+// MARK: - Registry Types
+
+private struct RegistryType {
+ let displayName: String
+ let commandName: String
+
+ func buildArguments(for package: Package) -> [String] {
+ let identifier = package.identifier
+ let version = package.version ?? ""
+
+ switch package.registryType {
+ case "npm":
+ return ["-y", version.isEmpty ? identifier : "\(identifier)@\(version)"]
+ case "pypi":
+ return [version.isEmpty ? identifier : "\(identifier)==\(version)"]
+ case "oci":
+ return ["run", "-i", "--rm", version.isEmpty ? identifier : "\(identifier):\(version)"]
+ case "nuget":
+ var args = [version.isEmpty ? identifier : "\(identifier)@\(version)", "--yes"]
+ if package.packageArguments?.isEmpty == false { args.append("--") }
+ return args
+ default:
+ return [version.isEmpty ? identifier : "\(identifier)@\(version)"]
+ }
+ }
+}
+
+private let registryTypes: [String: RegistryType] = [
+ "npm": RegistryType(displayName: "NPM", commandName: "npx"),
+ "pypi": RegistryType(displayName: "PyPI", commandName: "uvx"),
+ "oci": RegistryType(displayName: "OCI", commandName: "docker"),
+ "nuget": RegistryType(displayName: "NuGet", commandName: "dnx")
+]
+
+public extension Remote {
+ var transportType: TransportType {
+ switch self {
+ case .streamableHTTP(let transport):
+ return transport.type
+ case .sse(let transport):
+ return transport.type
+ }
+ }
+
+ var url: String {
+ switch self {
+ case .streamableHTTP(let transport):
+ return transport.url
+ case .sse(let transport):
+ return transport.url
+ }
+ }
+
+ var headers: [KeyValueInput]? {
+ switch self {
+ case .streamableHTTP(let transport):
+ return transport.headers
+ case .sse(let transport):
+ return transport.headers
+ }
+ }
+}
+
+// MARK: - MCP Registry Service
+
+@MainActor
+public class MCPRegistryService: ObservableObject {
+ public static let shared = MCPRegistryService()
+ public static let apiVersion = "v0.1"
+ @AppStorage(\.mcpRegistryBaseURL) var mcpRegistryBaseURL
+ @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
+ }
+
+ public func getRegistryBaseURL() throws -> String {
+ let url = mcpRegistryBaseURL.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !url.isEmpty else {
+ throw MCPRegistryError.registryURLNotConfigured
+ }
+ return url.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
+ }
+
+ public func getRegistryURL() throws -> String {
+ return try getRegistryBaseURL() + "/\(MCPRegistryService.apiVersion)/servers"
+ }
+
+ // MARK: - Installation Options
+
+ public func getAllInstallationOptions(for serverDetail: MCPRegistryServerDetail) -> [InstallationOption] {
+ var options: [InstallationOption] = []
+
+ // Add remote options
+ serverDetail.remotes?.enumerated().forEach { index, remote in
+ let config = createServerConfig(for: serverDetail, remote: remote)
+ options.append(InstallationOption(
+ displayName: "\(remote.transportType.displayText): \(remote.url)",
+ description: "Connect to remote server at \(remote.url)",
+ config: config,
+ isDefault: index == 0 && options.isEmpty
+ ))
+ }
+
+ // Add package options
+ serverDetail.packages?.enumerated().forEach { index, package in
+ let config = createServerConfig(for: serverDetail, package: package)
+ let registryDisplay = package.registryType.registryDisplayText
+
+ options.append(InstallationOption(
+ displayName: "\(registryDisplay) : \(package.identifier)",
+ description: "Install \(package.identifier) from \(registryDisplay)",
+ config: config,
+ isDefault: index == 0 && options.isEmpty
+ ))
+ }
+
+ return options
+ }
+
+ public func createServerConfiguration(for serverDetail: MCPRegistryServerDetail) throws -> [String: Any] {
+ let options = getAllInstallationOptions(for: serverDetail)
+ guard let defaultOption = options.first(where: { $0.isDefault }) ?? options.first else {
+ throw MCPRegistryError.noInstallationOptionsAvailable(serverName: serverDetail.name)
+ }
+ return defaultOption.config
+ }
+
+ // MARK: - Install/Uninstall Operations
+
+ public func installMCPServer(_ serverDetail: MCPRegistryServerDetail, installationOption: InstallationOption? = nil) async throws {
+ Logger.client.info("Installing MCP Server '\(serverDetail.name)'...")
+
+ let serverConfig: [String: Any]
+ if let option = installationOption {
+ serverConfig = option.config
+ } else {
+ serverConfig = try createServerConfiguration(for: serverDetail)
+ }
+
+ var currentConfig = loadConfiguration() ?? [:]
+ if currentConfig["servers"] == nil {
+ currentConfig["servers"] = [String: Any]()
+ }
+
+ guard var serversDict = currentConfig["servers"] as? [String: Any] else {
+ throw MCPRegistryError.invalidConfigurationStructure
+ }
+
+ serversDict[serverDetail.name] = serverConfig
+ currentConfig["servers"] = serversDict
+
+ try saveConfiguration(currentConfig)
+ Logger.client.info("Successfully installed MCP Server '\(serverDetail.name)'")
+ }
+
+ public func uninstallMCPServer(_ serverDetail: MCPRegistryServerDetail) async throws {
+ Logger.client.info("Uninstalling MCP Server '\(serverDetail.name)'...")
+
+ var currentConfig = loadConfiguration() ?? [:]
+ guard var serversDict = currentConfig["servers"] as? [String: Any] else {
+ throw MCPRegistryError.serverNotFound(serverName: serverDetail.name)
+ }
+
+ guard serversDict[serverDetail.name] != nil else {
+ throw MCPRegistryError.serverNotFound(serverName: serverDetail.name)
+ }
+
+ serversDict.removeValue(forKey: serverDetail.name)
+ currentConfig["servers"] = serversDict
+
+ try saveConfiguration(currentConfig)
+ Logger.client.info("Successfully uninstalled MCP Server '\(serverDetail.name)'")
+ }
+
+ // MARK: - Configuration Creation
+
+ public func createServerConfig(for serverDetail: MCPRegistryServerDetail, remote: Remote) -> [String: Any] {
+ var config: [String: Any] = [
+ "type": "http",
+ "url": remote.url
+ ]
+
+ // Add headers if present
+ if let headers = remote.headers, !headers.isEmpty {
+ let headersDict = Dictionary(headers.map { ($0.name, $0.value ?? "") }) { first, _ in first }
+ config["requestInit"] = ["headers": headersDict]
+ }
+
+ addMetadata(to: &config, serverDetail: serverDetail)
+ return config
+ }
+
+ public func createServerConfig(for serverDetail: MCPRegistryServerDetail, package: Package) -> [String: Any] {
+ let registryType = registryTypes[package.registryType]
+ let command = package.runtimeHint ?? registryType?.commandName ?? package.registryType
+
+ var config: [String: Any] = [
+ "type": "stdio",
+ "command": command
+ ]
+
+ // Build arguments
+ var args: [String] = []
+
+ // Runtime arguments
+ package.runtimeArguments?.forEach { args.append(contentsOf: extractArgumentValues(from: $0)) }
+
+ // Default arguments if no runtime arguments
+ if package.runtimeArguments?.isEmpty != false {
+ args
+ .append(
+ contentsOf: registryType?.buildArguments(for: package) ?? [package.identifier]
+ )
+ }
+
+ // Package arguments
+ package.packageArguments?.forEach { args.append(contentsOf: extractArgumentValues(from: $0)) }
+
+ config["args"] = args
+
+ // Environment variables
+ if let envVars = package.environmentVariables, !envVars.isEmpty {
+ config["env"] = Dictionary(envVars.map { ($0.name, $0.value ?? "") }) { first, _ in first }
+ }
+
+ addMetadata(to: &config, serverDetail: serverDetail)
+ return config
+ }
+
+ private func addMetadata(to config: inout [String: Any], serverDetail: MCPRegistryServerDetail) {
+ guard let baseURL = try? getRegistryBaseURL() else { return }
+
+ let api: [String: Any] = [
+ "baseUrl": baseURL,
+ "version": MCPRegistryService.apiVersion
+ ]
+
+ let mcpServer: [String: Any] = [
+ "name": Self.getServerName(from: serverDetail),
+ "version": serverDetail.version
+ ]
+
+ config["x-metadata"] = [
+ "registry": [
+ "api": api,
+ "mcpServer": mcpServer
+ ]
+ ]
+ }
+
+ private func extractArgumentValues(from argument: Argument) -> [String] {
+ switch argument {
+ case let .positional(positionalArg):
+ return (positionalArg.value ?? positionalArg.valueHint).map { [$0] } ?? []
+ case let .named(namedArg):
+ return [namedArg.name] + (namedArg.value.map { [$0] } ?? [])
+ }
+ }
+
+ // MARK: - Configuration File Management
+
+ private func loadConfiguration() -> [String: Any]? {
+ let configFileURL = URL(fileURLWithPath: mcpConfigFilePath)
+ guard FileManager.default.fileExists(atPath: mcpConfigFilePath),
+ let data = try? Data(contentsOf: configFileURL),
+ let jsonObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
+ return nil
+ }
+ return jsonObject
+ }
+
+ private func saveConfiguration(_ config: [String: Any]) throws {
+ let configFileURL = URL(fileURLWithPath: mcpConfigFilePath)
+
+ // Ensure directory exists
+ let configDirectory = configFileURL.deletingLastPathComponent()
+ if !FileManager.default.fileExists(atPath: configDirectory.path) {
+ try FileManager.default.createDirectory(at: configDirectory, withIntermediateDirectories: true)
+ }
+
+ // Save configuration
+ let jsonData = try JSONSerialization.data(withJSONObject: config, options: [.prettyPrinted])
+ try jsonData.write(to: configFileURL, options: .atomic)
+
+ // Note: UserDefaults update and notification will be handled by ToolsConfigView's file monitor
+ // with debouncing to prevent duplicate notifications
+ }
+
+ // MARK: - Server Installation Status
+
+ public func isServerInstalled(_ serverDetail: MCPRegistryServerDetail) -> Bool {
+ guard let config = loadConfiguration(),
+ let serversDict = config["servers"] as? [String: Any],
+ let expectedKey = expectedRegistryKey(for: serverDetail) else { return false }
+ return serversDict.values.contains { (value) -> Bool in
+ guard let serverConfigDict = value as? [String: Any],
+ let key = registryKey(from: serverConfigDict) else { return false }
+ return key == expectedKey
+ }
+ }
+
+ // MARK: - Option Installed Helpers
+
+ public func isPackageOptionInstalled(serverDetail: MCPRegistryServerDetail, package: Package) -> Bool {
+ guard isServerInstalled(serverDetail),
+ let config = loadConfiguration(),
+ let serversDict = config["servers"] as? [String: Any],
+ let expectedKey = expectedRegistryKey(for: serverDetail) else { return false }
+
+ let command = package.runtimeHint ?? registryTypes[package.registryType]?.commandName ?? (
+ package.registryType
+ )
+ let expectedArgsFirst: String? = {
+ var args: [String] = []
+ package.runtimeArguments?.forEach { args.append(contentsOf: extractArgumentValues(from: $0)) }
+ if package.runtimeArguments?.isEmpty != false {
+ args.append(
+ contentsOf: registryTypes[package.registryType]?.buildArguments(for: package) ?? [package.identifier]
+ )
+ }
+ package.packageArguments?.forEach { args.append(contentsOf: extractArgumentValues(from: $0)) }
+ return args.first
+ }()
+
+ return serversDict.values.contains { value in
+ guard let cfg = value as? [String: Any],
+ let key = registryKey(from: cfg),
+ key == expectedKey,
+ (cfg["type"] as? String)?.lowercased() == "stdio",
+ let c = cfg["command"] as? String,
+ let args = cfg["args"] as? [String] else { return false }
+ return c == command && args.first == expectedArgsFirst
+ }
+ }
+
+ public func isRemoteOptionInstalled(serverDetail: MCPRegistryServerDetail, remote: Remote) -> Bool {
+ guard isServerInstalled(serverDetail),
+ let config = loadConfiguration(),
+ let serversDict = config["servers"] as? [String: Any],
+ let expectedKey = expectedRegistryKey(for: serverDetail) else { return false }
+
+ return serversDict.values.contains { value in
+ guard let cfg = value as? [String: Any],
+ let key = registryKey(from: cfg),
+ key == expectedKey,
+ (cfg["type"] as? String)?.lowercased() == "http",
+ let url = cfg["url"] as? String else { return false }
+ return url == remote.url
+ }
+ }
+
+ public func createRegistryServerKey(registryBaseURL: String, serverName: String) -> String {
+ let trimmedBaseURL = registryBaseURL
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .trimmingCharacters(in: CharacterSet(charactersIn: "/"))
+ return "\(trimmedBaseURL)|\(serverName)"
+ }
+
+ // MARK: - Registry Key Helpers
+
+ private func expectedRegistryKey(for serverDetail: MCPRegistryServerDetail) -> String? {
+ guard let registryBaseURL = try? getRegistryBaseURL() else { return nil }
+ return createRegistryServerKey(
+ registryBaseURL: registryBaseURL,
+ serverName: Self.getServerName(from: serverDetail)
+ )
+ }
+
+ private func registryKey(from serverConfig: [String: Any]) -> String? {
+ guard let metadata = serverConfig["x-metadata"] as? [String: Any],
+ let registry = metadata["registry"] as? [String: Any],
+ let api = registry["api"] as? [String: Any],
+ let baseUrl = api["baseUrl"] as? String,
+ let mcpServer = registry["mcpServer"] as? [String: Any],
+ let name = mcpServer["name"] as? String else { return nil }
+ return createRegistryServerKey(registryBaseURL: baseUrl, serverName: name)
+ }
+}
+
+// MARK: - Error Types
+
+public enum MCPRegistryError: LocalizedError {
+ case registryURLNotConfigured
+ case noInstallationOptionsAvailable(serverName: String)
+ case invalidConfigurationStructure
+ case serverNotFound(serverName: String)
+ case configurationFileError(String)
+
+ public var errorDescription: String? {
+ switch self {
+ case .registryURLNotConfigured:
+ return "MCP Registry base URL is not configured. Please configure the registry URL in Settings > Tools > GitHub Copilot > MCP to browse and install servers from the registry."
+ case let .noInstallationOptionsAvailable(serverName):
+ return "Cannot create server configuration for '\(serverName)' - no installation options available"
+ case .invalidConfigurationStructure:
+ return "Invalid MCP configuration file structure"
+ case let .serverNotFound(serverName):
+ return "MCP Server '\(serverName)' not found in configuration"
+ case let .configurationFileError(message):
+ return "Configuration file error: \(message)"
+ }
+ }
+}
+
+// MARK: - Extensions
+
+extension String {
+ var registryDisplayText: String {
+ return registryTypes[self]?.displayName ?? self.capitalized
+ }
+}
diff --git a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryURLInputField.swift b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryURLInputField.swift
new file mode 100644
index 00000000..af7621e5
--- /dev/null
+++ b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryURLInputField.swift
@@ -0,0 +1,160 @@
+import GitHubCopilotService
+import SwiftUI
+import SharedUIComponents
+
+struct MCPRegistryURLInputField: View {
+ @Binding var urlText: String
+ @AppStorage(\.mcpRegistryBaseURLHistory) private var urlHistory
+ @State private var showHistory: Bool = false
+ @FocusState private var isFocused: Bool
+
+ let defaultMCPRegistryBaseURL = "https://api.mcp.github.com"
+ let maxURLLength: Int
+ let isSheet: Bool
+ let mcpRegistryEntry: MCPRegistryEntry?
+ let onValidationChange: ((Bool) -> Void)?
+ let onCommit: (() -> Void)?
+
+ private var isRegistryOnly: Bool {
+ mcpRegistryEntry?.registryAccess == .registryOnly
+ }
+
+ init(
+ urlText: Binding,
+ maxURLLength: Int = 2048,
+ isSheet: Bool = false,
+ mcpRegistryEntry: MCPRegistryEntry? = nil,
+ onValidationChange: ((Bool) -> Void)? = nil,
+ onCommit: (() -> Void)? = nil
+ ) {
+ self._urlText = urlText
+ self.maxURLLength = maxURLLength
+ self.isSheet = isSheet
+ self.mcpRegistryEntry = mcpRegistryEntry
+ self.onValidationChange = onValidationChange
+ self.onCommit = onCommit
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack(spacing: 8) {
+ if isSheet {
+ TextFieldsContainer {
+ TextField("MCP Registry Base URL", text: $urlText)
+ .focused($isFocused)
+ .disabled(isRegistryOnly)
+ .onChange(of: urlText) { newValue in
+ handleURLChange(newValue)
+ }
+ .onSubmit {
+ onCommit?()
+ }
+ }
+ } else {
+ TextField("MCP Registry Base URL:", text: $urlText)
+ .textFieldStyle(.roundedBorder)
+ .focused($isFocused)
+ .disabled(isRegistryOnly)
+ .onChange(of: urlText) { newValue in
+ handleURLChange(newValue)
+ }
+ .onSubmit {
+ onCommit?()
+ }
+ }
+
+ Menu {
+ ForEach(urlHistory, id: \.self) { url in
+ Button(url) {
+ urlText = url
+ isFocused = false
+ onCommit?()
+ }
+ }
+
+ Divider()
+
+ Button("Reset to Default") {
+ urlText = defaultMCPRegistryBaseURL
+ onCommit?()
+ }
+
+ if !urlHistory.isEmpty {
+ Button("Clear History") {
+ urlHistory = []
+ }
+ }
+ } label: {
+ Image(systemName: "chevron.down")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 11, height: 11)
+ .padding(isSheet ? 9 : 3)
+ }
+ .labelStyle(.iconOnly)
+ .menuIndicator(.hidden)
+ .buttonStyle(
+ HoverButtonStyle(
+ hoverColor: SecondarySystemFillColor,
+ backgroundColor: SecondarySystemFillColor,
+ cornerRadius: isSheet ? 12 : 6
+ )
+ )
+ .opacity(isRegistryOnly ? 0.5 : 1)
+ .disabled(isRegistryOnly)
+ }
+
+ if isRegistryOnly {
+ Badge(
+ text: "This URL is managed by \(mcpRegistryEntry!.owner.login) and cannot be modified",
+ level: .info,
+ icon: "info.circle.fill"
+ )
+ }
+ }
+ .onAppear {
+ if isRegistryOnly, let entryURL = mcpRegistryEntry?.url {
+ urlText = entryURL
+ }
+ }
+ .onChange(of: mcpRegistryEntry) { newEntry in
+ if newEntry?.registryAccess == .registryOnly, let entryURL = newEntry?.url {
+ urlText = entryURL
+ }
+ }
+ }
+
+ private func handleURLChange(_ newValue: String) {
+ // If registryOnly, force the URL back to the registry entry URL
+ if isRegistryOnly, let entryURL = mcpRegistryEntry?.url {
+ urlText = entryURL
+ return
+ }
+
+ let limitedText = String(newValue.prefix(maxURLLength))
+ if limitedText != newValue {
+ urlText = limitedText
+ }
+
+ let isValid = limitedText.isEmpty || isValidURL(limitedText)
+ onValidationChange?(isValid)
+ }
+
+ private func isValidURL(_ string: String) -> Bool {
+ guard !string.isEmpty else { return true }
+ return URL(string: string) != nil && (string.hasPrefix("http://") || string.hasPrefix("https://"))
+ }
+}
+
+extension Array where Element == String {
+ mutating func addToHistory(_ url: String, maxItems: Int = 10) {
+ // Remove if already exists
+ removeAll { $0 == url }
+ // Add to beginning
+ insert(url, at: 0)
+ // Keep only maxItems
+ if count > maxItems {
+ removeLast(count - maxItems)
+ }
+ }
+}
diff --git a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryURLSheet.swift b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryURLSheet.swift
new file mode 100644
index 00000000..efbc922a
--- /dev/null
+++ b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryURLSheet.swift
@@ -0,0 +1,75 @@
+import GitHubCopilotService
+import SwiftUI
+import SharedUIComponents
+
+struct MCPRegistryURLSheet: View {
+ @AppStorage(\.mcpRegistryBaseURL) private var mcpRegistryBaseURL
+ @AppStorage(\.mcpRegistryBaseURLHistory) private var mcpRegistryBaseURLHistory
+ @Environment(\.dismiss) private var dismiss
+ @State private var originalMcpRegistryBaseURL: String = ""
+ @State private var isFormValid: Bool = true
+
+ let mcpRegistryEntry: MCPRegistryEntry?
+ let onURLUpdated: (() -> Void)?
+
+ init(mcpRegistryEntry: MCPRegistryEntry? = nil, onURLUpdated: (() -> Void)? = nil) {
+ self.mcpRegistryEntry = mcpRegistryEntry
+ self.onURLUpdated = onURLUpdated
+ }
+
+ var body: some View {
+ Form {
+ VStack(alignment: .center, spacing: 20) {
+ HStack(alignment: .center) {
+ Spacer()
+ Text("MCP Registry Base URL").font(.headline)
+ Spacer()
+ AdaptiveHelpLink(action: openHelpLink)
+ }
+
+ VStack(alignment: .leading, spacing: 4) {
+ MCPRegistryURLInputField(
+ urlText: $originalMcpRegistryBaseURL,
+ isSheet: true,
+ mcpRegistryEntry: mcpRegistryEntry,
+ onValidationChange: { isValid in
+ isFormValid = isValid
+ }
+ )
+ }
+
+ HStack(spacing: 8) {
+ Spacer()
+ Button("Cancel", role: .cancel) { dismiss() }
+ Button("Update") {
+ // Check if URL changed before updating
+ originalMcpRegistryBaseURL = originalMcpRegistryBaseURL
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .trimmingCharacters(in: CharacterSet(charactersIn: "/"))
+ if originalMcpRegistryBaseURL != mcpRegistryBaseURL {
+ mcpRegistryBaseURL = originalMcpRegistryBaseURL
+ onURLUpdated?()
+ }
+ dismiss()
+ }
+ .buttonStyle(.borderedProminent)
+ .disabled(!isFormValid || mcpRegistryEntry?.registryAccess == .registryOnly)
+ }
+ }
+ .textFieldStyle(.plain)
+ .multilineTextAlignment(.trailing)
+ .padding(20)
+ }
+ .onAppear {
+ loadExistingURL()
+ }
+ }
+
+ private func loadExistingURL() {
+ originalMcpRegistryBaseURL = mcpRegistryBaseURL
+ }
+
+ private func openHelpLink() {
+ NSWorkspace.shared.open(URL(string: "https://docs.github.com/en/copilot/how-tos/provide-context/use-mcp/select-an-mcp-registry")!)
+ }
+}
diff --git a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryURLView.swift b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryURLView.swift
new file mode 100644
index 00000000..e01b90c5
--- /dev/null
+++ b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPRegistryURLView.swift
@@ -0,0 +1,202 @@
+import AppKit
+import Logger
+import SharedUIComponents
+import SwiftUI
+import Client
+import XPCShared
+import GitHubCopilotService
+import ComposableArchitecture
+
+struct MCPRegistryURLView: View {
+ @State private var isExpanded: Bool = false
+ @AppStorage(\.mcpRegistryBaseURL) var mcpRegistryBaseURL
+ @AppStorage(\.mcpRegistryBaseURLHistory) private var mcpRegistryBaseURLHistory
+ @State private var isLoading: Bool = false
+ @State private var tempURLText: String = ""
+ @State private var errorMessage: String = ""
+ @ObservedObject private var registryService = MCPRegistryService.shared
+
+ private let maxURLLength = 2048
+ private let mcpRegistryUrlVersion = "/v0.1/servers"
+
+ var body: some View {
+ WithPerceptionTracking {
+ VStack(spacing: 0) {
+ DisclosureSettingsRow(
+ isExpanded: $isExpanded,
+ accessibilityLabel: { $0 ? "Collapse mcp registry base URL section" : "Expand mcp registry base URL section" },
+ title: { Text("MCP Registry Base URL").font(.headline) + Text(" (Optional)") },
+ subtitle: { Text("Connect to available MCP servers for your AI workflows using the Registry URL.") },
+ actions: {
+ HStack(spacing: 8) {
+ if isLoading {
+ ProgressView().controlSize(.small)
+ }
+
+ Button {
+ isExpanded = true
+ } label: {
+ HStack(spacing: 0) {
+ Image(systemName: "square.and.pencil")
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 12, height: 12, alignment: .center)
+ .padding(4)
+ Text("Edit URL")
+ }
+ .conditionalFontWeight(.semibold)
+ }
+ .buttonStyle(.bordered)
+ .help("Configure your MCP Registry Base URL")
+ .disabled(registryService.mcpRegistryEntries?.first?.registryAccess == .registryOnly)
+
+ Button { Task{ await loadMCPServers() } } label: {
+ HStack(spacing: 0) {
+ Image(systemName: "square.grid.2x2")
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 12, height: 12, alignment: .center)
+ .padding(4)
+ Text("Browse MCP Servers...")
+ }
+ .conditionalFontWeight(.semibold)
+ }
+ .buttonStyle(.bordered)
+ .help("Browse MCP Servers")
+ }
+ .padding(.vertical, 12)
+ }
+ )
+
+ if isExpanded {
+ VStack(alignment: .leading, spacing: 8) {
+ MCPRegistryURLInputField(
+ urlText: $tempURLText,
+ maxURLLength: maxURLLength,
+ isSheet: false,
+ mcpRegistryEntry: registryService.mcpRegistryEntries?.first,
+ onValidationChange: { _ in
+ // Only validate, don't update mcpRegistryURL here
+ },
+ onCommit: {
+ // Update mcpRegistryURL when user presses Enter
+ tempURLText = tempURLText
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .trimmingCharacters(in: CharacterSet(charactersIn: "/"))
+ if tempURLText != mcpRegistryBaseURL {
+ mcpRegistryBaseURL = tempURLText
+ }
+ }
+ )
+
+ if !errorMessage.isEmpty {
+ Badge(text: errorMessage, level: .danger, icon: "xmark.circle.fill")
+ }
+ }
+ .padding(.leading, 36)
+ .padding([.trailing, .bottom], 20)
+ .background(QuaternarySystemFillColor.opacity(0.75))
+ .transition(.opacity.combined(with: .scale(scale: 1, anchor: .top)))
+ .onAppear {
+ tempURLText = mcpRegistryBaseURL
+ }
+ }
+ }
+ .settingsContainerStyle(isExpanded: isExpanded)
+ .onAppear {
+ tempURLText = mcpRegistryBaseURL
+ Task { await getMCPRegistryAllowlist() }
+ }
+ .onReceive(DistributedNotificationCenter.default().publisher(for: .authStatusDidChange)) { _ in
+ Task { await getMCPRegistryAllowlist() }
+ }
+ .onChange(of: mcpRegistryBaseURL) { newValue in
+ // Update the temp text to reflect the new URL
+ tempURLText = newValue
+ Task { await updateGalleryWindowIfOpen() }
+ }
+ .onChange(of: registryService.mcpRegistryEntries) { _ in
+ Task { await updateGalleryWindowIfOpen() }
+ }
+ }
+ }
+
+ private func loadMCPServers() async {
+ // Update mcpRegistryURL with current tempURLText before loading
+ tempURLText = tempURLText.trimmingCharacters(in: .whitespacesAndNewlines)
+ if tempURLText != mcpRegistryBaseURL {
+ mcpRegistryBaseURL = tempURLText
+ }
+
+ isLoading = true
+ defer { isLoading = false }
+ do {
+ let service = try getService()
+ let serverList = try await service.listMCPRegistryServers(
+ .init(baseUrl: mcpRegistryBaseURL + mcpRegistryUrlVersion, limit: 30, version: "latest")
+ )
+
+ guard let serverList = serverList, !serverList.servers.isEmpty else {
+ Logger.client.info("No MCP servers found at registry URL: \(mcpRegistryBaseURL)")
+ return
+ }
+
+ // Add to history on successful load
+ mcpRegistryBaseURLHistory.addToHistory(mcpRegistryBaseURL)
+ errorMessage = ""
+
+ MCPServerGalleryWindow.open(serverList: serverList, mcpRegistryEntry: registryService.mcpRegistryEntries?.first)
+ } catch {
+ Logger.client.error("Failed to load MCP servers from registry: \(error.localizedDescription)")
+ if let serviceError = error as? XPCExtensionServiceError {
+ errorMessage = serviceError.underlyingError?.localizedDescription ?? serviceError.localizedDescription
+ } else {
+ errorMessage = error.localizedDescription
+ }
+ isExpanded = true
+ }
+ }
+
+ private func getMCPRegistryAllowlist() async {
+ isLoading = true
+ defer { isLoading = false }
+
+ 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
+ }
+ }
+
+ private func updateGalleryWindowIfOpen() async {
+ // Only update if the gallery window is currently open
+ guard MCPServerGalleryWindow.isOpen() else {
+ return
+ }
+
+ isLoading = true
+ defer { isLoading = false }
+
+ // Let the view model handle the entire update flow including clearing and fetching
+ if let error = await MCPServerGalleryWindow.refreshFromURL(mcpRegistryEntry: registryService.mcpRegistryEntries?.first) {
+ // Display error in the URL view
+ if let serviceError = error as? XPCExtensionServiceError {
+ errorMessage = serviceError.underlyingError?.localizedDescription ?? serviceError.localizedDescription
+ } else {
+ errorMessage = error.localizedDescription
+ }
+ isExpanded = true
+ } else {
+ errorMessage = ""
+ }
+ }
+}
+
+#Preview {
+ MCPRegistryURLView()
+ .padding()
+ .frame(width: 900)
+}
diff --git a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerDetailSheet.swift b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerDetailSheet.swift
new file mode 100644
index 00000000..b462519a
--- /dev/null
+++ b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerDetailSheet.swift
@@ -0,0 +1,589 @@
+import SwiftUI
+import AppKit
+import GitHubCopilotService
+import SharedUIComponents
+import Foundation
+
+struct MCPServerDetailSheet: View {
+ let server: MCPRegistryServerDetail
+ let meta: ServerMeta?
+ @State private var selectedTab = TabType.Packages
+ @State private var expandedPackages: Set = []
+ @State private var expandedRemotes: Set = []
+ @State private var packageConfigs: [Int: [String: Any]] = [:]
+ @State private var remoteConfigs: [Int: [String: Any]] = [:]
+ // Track installation progress per item so we can disable buttons / show feedback
+ @State private var installingPackages: Set = []
+ @State private var installingRemotes: Set = []
+ // Track whether the server (any option) is already installed
+ @State private var isInstalled: Bool
+ // Overwrite confirmation alert
+ @State private var showOverwriteAlert: Bool = false
+ @State private var pendingInstallAction: (() -> Void)? = nil
+
+ @Environment(\.dismiss) private var dismiss
+
+ enum TabType: String, CaseIterable, Identifiable {
+ case Packages, Remotes, Metadata
+ var id: Self { self }
+ }
+
+ init(response: MCPRegistryServerResponse) {
+ self.server = response.server
+ self.meta = response.meta
+ // Determine installed status using registry service (same logic as gallery view)
+ _isInstalled = State(initialValue: MCPRegistryService.shared.isServerInstalled(server))
+ }
+
+ // Shared visual constants
+ private let labelColumnWidth: CGFloat = 80
+ private let detailTopPadding: CGFloat = 6
+
+ var body: some View {
+ VStack(spacing: 0) {
+ // Header
+ headerSection
+
+ // Tab selector
+ tabSelector
+
+ // Content
+ OverlayScrollView {
+ VStack(alignment: .leading, spacing: 16) {
+ switch selectedTab {
+ case .Packages:
+ packagesTab
+ case .Remotes:
+ remotesTab
+ case .Metadata:
+ metadataTab
+ }
+ }
+ .padding(28)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .frame(maxHeight: 400)
+ }
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button(action: { dismiss() }) { Text("Close") }
+ }
+ ToolbarItem(placement: .secondaryAction) {
+ if isInstalled {
+ Button("Open Config") { openConfig() }
+ .help("Open mcp.json")
+ }
+ }
+ }
+ .toolbarRole(.automatic)
+ .frame(width: 600, height: 450)
+ .background(Color(nsColor: .controlBackgroundColor))
+ .onAppear {
+ isInstalled = MCPRegistryService.shared.isServerInstalled(server)
+ }
+ .alert("Overwrite Existing Installation?", isPresented: $showOverwriteAlert) {
+ Button("Cancel", role: .cancel) { pendingInstallAction = nil }
+ Button("Overwrite", role: .destructive) {
+ pendingInstallAction?()
+ pendingInstallAction = nil
+ }
+ } message: {
+ Text("Installing this option will replace the currently installed variant of this server.")
+ }
+ }
+
+ // MARK: - Header Section
+
+ private var headerSection: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ HStack(alignment: .center) {
+ Text(server.title ?? server.name)
+ .font(.system(size: 18, weight: .semibold))
+
+ if let status = meta?.official?.status, status == .deprecated {
+ statusBadge(status)
+ }
+
+ Spacer()
+ }
+
+ HStack(spacing: 24) {
+ HStack(spacing: 6) {
+ Image(systemName: "tag")
+ Text(server.version)
+ }
+ .font(.system(size: 12, design: .monospaced))
+ .foregroundColor(.secondary)
+
+ if let publishedAt = meta?.official?.publishedAt {
+ dateMetadataTag(title: "Published ", dateString: publishedAt, image: "clock.arrow.trianglehead.counterclockwise.rotate.90")
+ }
+
+ if let updatedAt = meta?.official?.updatedAt {
+ dateMetadataTag(title: "Updated ", dateString: updatedAt, image: "icloud.and.arrow.up")
+ }
+
+ if let repo = server.repository, !repo.url.isEmpty, !repo.source.isEmpty {
+ if let repoURL = URL(string: repo.url) {
+ HStack(spacing: 6) {
+ Image(systemName: "link")
+ Link(destination: repoURL) {
+ Text("Repository")
+ }
+ .onHover { hovering in
+ if hovering {
+ NSCursor.pointingHand.push()
+ } else {
+ NSCursor.pop()
+ }
+ }
+ }
+ .font(.system(size: 12))
+ .foregroundColor(.secondary)
+ }
+ }
+ }
+
+ Text(server.description)
+ .font(.system(size: 13))
+ .foregroundColor(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ .lineSpacing(2)
+ .padding(.top, 4)
+ }
+ .padding(28)
+ .background(Color(nsColor: .windowBackgroundColor))
+ }
+
+ private func dateMetadataTag(title: String, dateString: String, image: String) -> some View {
+ HStack(spacing: 6) {
+ Image(systemName: image)
+ if let date = parseDate(dateString) {
+ (Text("\(title)\(relativeDateString(date))"))
+ .help(formatExactDate(date))
+ } else {
+ Text("\(title) \(dateString)").help(dateString)
+ }
+ }
+ .font(.system(size: 12))
+ .foregroundColor(.secondary)
+ }
+
+ // MARK: - Tab Selector
+
+ private var tabSelector: some View {
+ HStack(spacing: 0) {
+ Picker("", selection: $selectedTab) {
+ Text("Packages (\(server.packages?.count ?? 0))")
+ .tag(TabType.Packages)
+ Text("Remotes (\(server.remotes?.count ?? 0))")
+ .tag(TabType.Remotes)
+ if meta?.official != nil {
+ Text("Metadata")
+ .tag(TabType.Metadata)
+ }
+ }
+ .pickerStyle(.segmented)
+ }
+ .padding(.horizontal, 20)
+ .padding(.vertical, 12)
+ .background(Color(nsColor: .controlBackgroundColor).opacity(0.3))
+ .overlay(
+ Rectangle()
+ .fill(Color(nsColor: .separatorColor))
+ .frame(height: 1),
+ alignment: .bottom
+ )
+ }
+
+ // MARK: - Packages Tab
+
+ private var packagesTab: some View {
+ Group {
+ if let packages = server.packages, !packages.isEmpty {
+ ForEach(Array(packages.enumerated()), id: \.offset) { index, package in
+ packageItem(package, index: index)
+ }
+ } else {
+ EmptyStateView(message: "No packages available for this server", type: .Packages)
+ }
+ }
+ }
+
+ private func packageItem(_ package: Package, index: Int) -> some View {
+ let isExpanded = expandedPackages.contains(index)
+ let optionInstalled = MCPRegistryService.shared.isPackageOptionInstalled(serverDetail: server, package: package)
+ let metadata: [ServerInstallationOptionView.Metadata] = {
+ var rows: [ServerInstallationOptionView.Metadata] = []
+ rows.append(.init(label: "ID", value: package.identifier, monospaced: true))
+ if let registryURL = package.registryBaseUrl {
+ rows.append(.init(label: "Registry", value: registryURL))
+ }
+ if let runtime = package.runtimeHint { rows.append(.init(label: "Runtime", value: runtime)) }
+ return rows
+ }()
+ return ServerInstallationOptionView(
+ title: package.registryType.registryDisplayText,
+ iconSystemName: "shippingbox",
+ versionTag: package.version,
+ metadata: metadata,
+ isExpanded: isExpanded,
+ isInstalled: isInstalled, // overall server installed
+ isInstalling: installingPackages.contains(index),
+ showUninstall: optionInstalled,
+ labelColumnWidth: labelColumnWidth,
+ onToggleExpand: {
+ if isExpanded {
+ expandedPackages.remove(index)
+ } else {
+ expandedPackages.insert(index)
+ if packageConfigs[index] == nil { packageConfigs[index] = generateServerConfig(for: package) }
+ }
+ },
+ onInstall: { handlePackageInstallButton(package, index: index, optionInstalled: optionInstalled) },
+ onUninstall: { uninstallServer() },
+ config: packageConfigs[index]
+ )
+ }
+
+ // MARK: - Remotes Tab
+
+ private var remotesTab: some View {
+ Group {
+ if let remotes = server.remotes, !remotes.isEmpty {
+ ForEach(Array(remotes.enumerated()), id: \.offset) { index, remote in
+ remoteItem(remote, index: index)
+ }
+ } else {
+ EmptyStateView(
+ message: "No remote endpoints configured for this server",
+ type: .Remotes
+ )
+ }
+ }
+ }
+
+ private func remoteItem(_ remote: Remote, index: Int) -> some View {
+ let isExpanded = expandedRemotes.contains(index)
+ let optionInstalled = MCPRegistryService.shared.isRemoteOptionInstalled(serverDetail: server, remote: remote)
+ let metadata: [ServerInstallationOptionView.Metadata] = [
+ .init(label: "URL", value: remote.url, monospaced: true)
+ ]
+ return ServerInstallationOptionView(
+ title: remote.transportType.displayText,
+ iconSystemName: "globe",
+ versionTag: nil,
+ metadata: metadata,
+ isExpanded: isExpanded,
+ isInstalled: isInstalled,
+ isInstalling: installingRemotes.contains(index),
+ showUninstall: optionInstalled,
+ labelColumnWidth: labelColumnWidth,
+ onToggleExpand: {
+ if isExpanded {
+ expandedRemotes.remove(index)
+ } else {
+ expandedRemotes.insert(index)
+ if remoteConfigs[index] == nil { remoteConfigs[index] = generateServerConfig(for: remote) }
+ }
+ },
+ onInstall: { handleRemoteInstallButton(remote, index: index, optionInstalled: optionInstalled) },
+ onUninstall: { uninstallServer() },
+ config: remoteConfigs[index]
+ )
+ }
+
+ // MARK: - Metadata Tab
+
+ private var metadataTab: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ if let officialMeta = meta?.official {
+ officialMetadataSection(officialMeta)
+ }
+ }
+ }
+
+ private func officialMetadataSection(_ official: OfficialMeta) -> some View {
+ VStack(alignment: .leading, spacing: 12) {
+ HStack {
+ Text("Official Registry")
+ .font(.system(size: 14, weight: .medium))
+ }
+
+ VStack(alignment: .leading, spacing: 8) {
+ if let publishedAt = official.publishedAt {
+ metadataRow(
+ label: "Published",
+ value: parseDate(publishedAt) != nil ? formatExactDate(
+ parseDate(publishedAt)!
+ ) : publishedAt
+ )
+ }
+
+ if let updatedAt = official.updatedAt {
+ metadataRow(
+ label: "Updated",
+ value: parseDate(updatedAt) != nil ? formatExactDate(
+ parseDate(updatedAt)!
+ ) : updatedAt
+ )
+ }
+ }
+ }
+ .padding(16)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(
+ RoundedRectangle(cornerRadius: 8)
+ .fill(Color(nsColor: .controlBackgroundColor))
+ .overlay(
+ RoundedRectangle(cornerRadius: 8)
+ .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
+ )
+ )
+ }
+
+ private func metadataRow(label: String, value: String, isLink: Bool = false) -> some View {
+ HStack(spacing: 8) {
+ Text(label)
+ .font(.system(size: 12, weight: .medium))
+ .foregroundColor(.secondary)
+ .frame(width: 80, alignment: .leading)
+
+ if isLink, let url = URL(string: value) {
+ Link(value, destination: url)
+ .font(.system(size: 12, design: .monospaced))
+ .foregroundColor(.blue)
+ } else {
+ Text(value)
+ .font(.system(size: 12, design: label.contains("ID") || label.contains("Commit") ? .monospaced : .default))
+ .foregroundColor(.primary)
+ .textSelection(.enabled)
+ }
+ }
+ }
+
+ private func serverConfigView(_ config: [String: Any]) -> some View {
+ ZStack(alignment: .topTrailing) {
+ VStack(alignment: .leading, spacing: 8) {
+ Text(formatConfigAsJSON(config))
+ .font(.system(.callout, design: .monospaced))
+ .textSelection(.enabled)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .fixedSize(horizontal: false, vertical: true)
+ .padding(.bottom, 2)
+ }
+ .padding(12)
+
+ CopyButton {
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(formatConfigAsJSON(config), forType: .string)
+ }
+ .padding(6)
+ .help("Copy configuration to clipboard")
+ }
+ .background(
+ RoundedRectangle(cornerRadius: 6)
+ .fill(Color(nsColor: .textBackgroundColor).opacity(0.5))
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: 6)
+ .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
+ )
+ }
+
+
+ private func formatConfigAsJSON(_ config: [String: Any]) -> String {
+ do {
+ let jsonData = try JSONSerialization.data(withJSONObject: config, options: [.prettyPrinted, .sortedKeys])
+ return String(data: jsonData, encoding: .utf8) ?? "{}"
+ } catch {
+ return "{}"
+ }
+ }
+
+ // MARK: - Configuration Generation Helpers
+
+ private func generateServerConfig(for package: Package) -> [String: Any] {
+ return MCPRegistryService.shared.createServerConfig(for: server, package: package)
+ }
+
+ private func generateServerConfig(for remote: Remote) -> [String: Any] {
+ return MCPRegistryService.shared.createServerConfig(for: server, remote: remote)
+ }
+
+ // MARK: - Install Helpers
+
+ private func performPackageInstall(_ package: Package, index: Int) {
+ guard !installingPackages.contains(index) else { return }
+ installingPackages.insert(index)
+ Task {
+ let config = packageConfigs[index] ?? generateServerConfig(for: package)
+ // Cache generated config for preview if needed later
+ if packageConfigs[index] == nil { packageConfigs[index] = config }
+ let option = InstallationOption(
+ displayName: package.registryType.registryDisplayText,
+ description: "Install \(package.identifier)",
+ config: config
+ )
+ do {
+ try await MCPRegistryService.shared.installMCPServer(server, installationOption: option)
+ // Mark installed locally so UI reflects the state immediately
+ isInstalled = true
+ } catch {
+ // Silently fail for now; could surface error UI later
+ }
+ installingPackages.remove(index)
+ }
+ }
+
+ private func handlePackageInstallButton(_ package: Package, index: Int, optionInstalled: Bool) {
+ if isInstalled && !optionInstalled {
+ // Show overwrite confirmation
+ pendingInstallAction = { performPackageInstall(package, index: index) }
+ showOverwriteAlert = true
+ } else {
+ performPackageInstall(package, index: index)
+ }
+ }
+
+ private func performRemoteInstall(_ remote: Remote, index: Int) {
+ guard !installingRemotes.contains(index) else { return }
+ installingRemotes.insert(index)
+ Task {
+ let config = remoteConfigs[index] ?? generateServerConfig(for: remote)
+ if remoteConfigs[index] == nil { remoteConfigs[index] = config }
+ let option = InstallationOption(
+ displayName: "\(remote.transportType.rawValue)",
+ description: "Install remote endpoint \(remote.url)",
+ config: config
+ )
+ do {
+ try await MCPRegistryService.shared.installMCPServer(server, installationOption: option)
+ isInstalled = true
+ } catch {
+ // Silently fail for now
+ }
+ installingRemotes.remove(index)
+ }
+ }
+
+ private func handleRemoteInstallButton(_ remote: Remote, index: Int, optionInstalled: Bool) {
+ if isInstalled && !optionInstalled {
+ pendingInstallAction = { performRemoteInstall(remote, index: index) }
+ showOverwriteAlert = true
+ } else {
+ performRemoteInstall(remote, index: index)
+ }
+ }
+
+ private func uninstallServer() {
+ Task {
+ do {
+ try await MCPRegistryService.shared.uninstallMCPServer(server)
+ isInstalled = false
+ } catch {
+ // TODO: Consider surfacing error to user
+ }
+ }
+ }
+
+ // MARK: - Helper Views
+
+ private func statusBadge(_ status: ServerStatus) -> some View {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .font(.system(size: 14, weight: .medium))
+ .foregroundColor(Color.orange)
+ .padding(.horizontal, 6)
+ .help("The server is deprecated.")
+ }
+
+ private struct EmptyStateView: View {
+ let message: String
+ let type: PackageType
+
+ enum PackageType: String {
+ case Packages, Remotes, Metadata
+ }
+
+ var Logo: some View {
+ switch type {
+ case .Packages:
+ return Image(systemName: "shippingbox")
+ case .Remotes:
+ return Image(systemName: "globe")
+ case .Metadata:
+ return Image(systemName: "info.circle")
+ }
+ }
+
+ var body: some View {
+ VStack(spacing: 12) {
+ Logo.font(.system(size: 32))
+
+ Text(message)
+ .font(.system(size: 13))
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 40)
+ }
+ }
+
+ // MARK: - Utilities
+
+ private func parseDate(_ dateString: String) -> Date? {
+ // Try multiple ISO8601 formatters in order of specificity
+ let formatters: [ISO8601DateFormatter] = [
+ {
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ return formatter
+ }(),
+ {
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime]
+ return formatter
+ }(),
+ {
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withYear, .withMonth, .withDay, .withTime, .withTimeZone]
+ return formatter
+ }(),
+ {
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withYear, .withMonth, .withDay, .withTime]
+ return formatter
+ }()
+ ]
+
+ // Try each formatter until one succeeds
+ for formatter in formatters {
+ if let date = formatter.date(from: dateString) {
+ return date
+ }
+ }
+
+ return nil
+ }
+
+ private func formatExactDate(_ date: Date) -> String {
+ let formatter = DateFormatter()
+ formatter.dateStyle = .full
+ formatter.timeStyle = .medium
+ return formatter.string(from: date)
+ }
+
+ private func relativeDateString(_ date: Date) -> String {
+ let formatter = RelativeDateTimeFormatter()
+ formatter.unitsStyle = .full
+ return formatter.localizedString(for: date, relativeTo: Date())
+ }
+
+ // MARK: - Open Config / Selection Support
+
+ private func openConfig() {
+ // Simplified to just open the MCP config file, mirroring manual install behavior.
+ let url = URL(fileURLWithPath: mcpConfigFilePath)
+ NSWorkspace.shared.open(url)
+ }
+}
+
diff --git a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerGalleryView.swift b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerGalleryView.swift
new file mode 100644
index 00000000..0082b480
--- /dev/null
+++ b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerGalleryView.swift
@@ -0,0 +1,346 @@
+import AppKit
+import Client
+import CryptoKit
+import GitHubCopilotService
+import Logger
+import SharedUIComponents
+import SwiftUI
+import XPCShared
+
+enum MCPServerGalleryWindow {
+ static let identifier = "MCPServerGalleryWindow"
+ private static weak var currentViewModel: MCPServerGalleryViewModel?
+
+ @MainActor static func open(
+ serverList: MCPRegistryServerList,
+ mcpRegistryEntry: MCPRegistryEntry? = nil
+ ) {
+ if let existing = NSApp.windows.first(where: { $0.identifier?.rawValue == identifier }) {
+ // Update existing window with new data
+ update(serverList: serverList, mcpRegistryEntry: mcpRegistryEntry)
+ existing.makeKeyAndOrderFront(nil)
+ NSApp.activate(ignoringOtherApps: true)
+ return
+ }
+
+ let viewModel = MCPServerGalleryViewModel(
+ initialList: serverList,
+ mcpRegistryEntry: mcpRegistryEntry
+ )
+ currentViewModel = viewModel
+
+ let controller = NSHostingController(
+ rootView: MCPServerGalleryView(
+ viewModel: viewModel
+ )
+ )
+
+ let window = NSWindow(contentViewController: controller)
+ window.title = "MCP Servers Marketplace"
+ window.identifier = NSUserInterfaceItemIdentifier(identifier)
+ window.setContentSize(NSSize(width: 800, height: 600))
+ window.minSize = NSSize(width: 600, height: 400)
+ window.styleMask.insert([.titled, .closable, .resizable, .miniaturizable])
+ window.isReleasedWhenClosed = false
+ window.center()
+ window.makeKeyAndOrderFront(nil)
+ NSApp.activate(ignoringOtherApps: true)
+ }
+
+ @MainActor static func update(
+ serverList: MCPRegistryServerList,
+ mcpRegistryEntry: MCPRegistryEntry? = nil
+ ) {
+ currentViewModel?.updateData(serverList: serverList, mcpRegistryEntry: mcpRegistryEntry)
+ }
+
+ @MainActor static func refreshFromURL(mcpRegistryEntry: MCPRegistryEntry? = nil) async -> Error? {
+ return await currentViewModel?.refreshFromURL(mcpRegistryEntry: mcpRegistryEntry)
+ }
+
+ static func isOpen() -> Bool {
+ return NSApp.windows.first(where: { $0.identifier?.rawValue == identifier }) != nil
+ }
+}
+
+// MARK: - Stable ID helper
+
+extension MCPRegistryServerResponse {
+ var stableID: String {
+ server.name + server.version
+ }
+}
+
+private struct IdentifiableServerResponse: Identifiable {
+ let response: MCPRegistryServerResponse
+ var id: String { response.stableID }
+}
+
+struct MCPServerGalleryView: View {
+ @ObservedObject var viewModel: MCPServerGalleryViewModel
+ @State private var isShowingURLSheet = false
+ @State private var searchTask: Task?
+
+ init(viewModel: MCPServerGalleryViewModel) {
+ self.viewModel = viewModel
+ }
+
+ // MARK: - Body
+
+ var body: some View {
+ VStack(spacing: 0) {
+ if let error = viewModel.lastError {
+ if let serviceError = error as? XPCExtensionServiceError {
+ Badge(text: serviceError.underlyingError?.localizedDescription ?? serviceError.localizedDescription, level: .danger, icon: "xmark.circle.fill")
+ } else {
+ Badge(text: error.localizedDescription, level: .danger, icon: "xmark.circle.fill")
+ }
+ }
+
+ tableHeaderView
+ serverListView
+ }
+ .padding(20)
+ .background(Color(nsColor: .controlBackgroundColor))
+ .background(.ultraThinMaterial)
+ .onAppear {
+ viewModel.loadInstalledServers()
+ }
+ .sheet(isPresented: $isShowingURLSheet) {
+ urlSheet
+ }
+ .sheet(isPresented: Binding(
+ get: { viewModel.infoSheetServer != nil },
+ set: { isPresented in
+ if !isPresented {
+ viewModel.dismissInfo()
+ }
+ }
+ )) {
+ if let server = viewModel.infoSheetServer {
+ infoSheet(server)
+ }
+ }
+ .searchable(text: $viewModel.searchText, prompt: "Search")
+ .onChange(of: viewModel.searchText) { newValue in
+ // Debounce search input before triggering a new server-side query
+ searchTask?.cancel()
+ searchTask = Task { @MainActor in
+ try? await Task.sleep(nanoseconds: 300_000_000) // 0.3s
+ if !Task.isCancelled {
+ viewModel.refreshForSearch()
+ }
+ }
+ }
+ .toolbar {
+ ToolbarItem {
+ Button(action: { viewModel.refresh() }) {
+ Image(systemName: "arrow.clockwise")
+ }
+ .help("Refresh")
+ }
+
+ ToolbarItem {
+ Button(action: { isShowingURLSheet = true }) {
+ Image(systemName: "square.and.pencil")
+ }
+ .help("Configure your MCP Registry Base URL")
+ }
+ }
+ }
+
+ private var tableHeaderView: some View {
+ VStack(spacing: 0) {
+ HStack {
+ Text("Name")
+ .font(.system(size: 11, weight: .bold))
+ .padding(.horizontal, 8)
+ .frame(width: 220, alignment: .leading)
+
+ Divider().frame(height: 20)
+
+ Text("Description")
+ .font(.system(size: 11, weight: .medium))
+ .foregroundColor(.secondary)
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ HStack {
+ Text("Actions")
+ .font(.system(size: 11, weight: .medium))
+ .foregroundColor(.secondary)
+ }
+ .padding(.trailing, 8)
+ .frame(width: 120, alignment: .leading)
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 6)
+ .background(Color.clear)
+
+ Divider()
+ }
+ }
+
+ private var serverListView: some View {
+ ZStack {
+ ScrollView {
+ LazyVStack(spacing: 0) {
+ serverRows
+
+ if viewModel.shouldShowLoadMoreSentinel {
+ Color.clear
+ .frame(height: 1)
+ .onAppear { viewModel.loadMoreIfNeeded() }
+ .accessibilityHidden(true)
+ }
+
+ if viewModel.isLoadingMore {
+ HStack {
+ Spacer()
+ ProgressView()
+ .padding(.vertical, 12)
+ Spacer()
+ }
+ }
+ }
+ }
+
+ if viewModel.isRefreshing {
+ VStack(spacing: 12) {
+ ProgressView()
+ Text("Loading servers...")
+ .font(.system(size: 13))
+ .foregroundColor(.secondary)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(Color(nsColor: .controlBackgroundColor).opacity(0.95))
+ }
+ }
+ }
+
+ private var serverRows: some View {
+ ForEach(Array(viewModel.filteredServers.enumerated()), id: \.element.stableID) { index, server in
+ let isInstalled = viewModel.isServerInstalled(serverId: server.stableID)
+ row(for: server, index: index, isInstalled: isInstalled)
+ .background(rowBackground(for: index))
+ .cornerRadius(8)
+ .onAppear {
+ handleRowAppear(index: index)
+ }
+ }
+ }
+
+ private var urlSheet: some View {
+ MCPRegistryURLSheet(
+ mcpRegistryEntry: viewModel.mcpRegistryEntry,
+ onURLUpdated: {
+ viewModel.refresh()
+ }
+ )
+ .frame(width: 500, height: 200)
+ }
+
+ private func rowBackground(for index: Int) -> Color {
+ index.isMultiple(of: 2) ? Color.clear : Color.primary.opacity(0.03)
+ }
+
+ private func handleRowAppear(index: Int) {
+ let currentFilteredCount = viewModel.filteredServers.count
+ let totalServerCount = viewModel.servers.count
+
+ // Prefetch when approaching the end of filtered results
+ if index >= currentFilteredCount - 5 {
+ // If we're filtering and the filtered results are small compared to total servers,
+ // or if we're near the end of all available data, try to load more
+ if currentFilteredCount < 20 || index >= totalServerCount - 5 {
+ viewModel.loadMoreIfNeeded()
+ }
+ }
+ }
+
+ // MARK: - Subviews
+
+ private func row(for response: MCPRegistryServerResponse, index: Int, isInstalled: Bool) -> some View {
+ HStack {
+ Text(response.server.title ?? response.server.name)
+ .fontWeight(.medium)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ .padding(.horizontal, 8)
+ .frame(width: 220, alignment: .leading)
+
+ Divider().frame(height: 20).foregroundColor(Color.clear)
+
+ Text(response.server.description)
+ .fontWeight(.medium)
+ .foregroundColor(.secondary)
+ .lineLimit(1)
+ .truncationMode(.tail)
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ HStack(spacing: 8) {
+ if isInstalled {
+ Button("Uninstall") {
+ Task {
+ await viewModel.uninstallServer(response.server)
+ }
+ }
+ .buttonStyle(DestructiveButtonStyle())
+ .help("Uninstall")
+ } else {
+ 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")
+ }
+
+ Button {
+ viewModel.showInfo(response)
+ } label: {
+ Image(systemName: "info.circle")
+ .font(.system(size: 13))
+ .foregroundColor(.primary)
+ .multilineTextAlignment(.trailing)
+ }
+ .buttonStyle(.plain)
+ .help("View Details")
+ }
+ .padding(.horizontal, 8)
+ .frame(width: 120, alignment: .leading)
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 10)
+ }
+
+ private func infoSheet(_ response: MCPRegistryServerResponse) -> some View {
+ MCPServerDetailSheet(response: response)
+ }
+}
+
+func defaultInstallation(for server: MCPRegistryServerDetail) -> String {
+ // Get the first available type from remotes or packages
+ if let firstRemote = server.remotes?.first {
+ return firstRemote.transportType.rawValue
+ }
+ if let firstPackage = server.packages?.first {
+ return firstPackage.registryType
+ }
+ return ""
+}
diff --git a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerGalleryViewModel.swift b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerGalleryViewModel.swift
new file mode 100644
index 00000000..26cfaf63
--- /dev/null
+++ b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerGalleryViewModel.swift
@@ -0,0 +1,320 @@
+import Client
+import CryptoKit
+import Foundation
+import GitHubCopilotService
+import Logger
+import SwiftUI
+
+@MainActor
+final class MCPServerGalleryViewModel: ObservableObject {
+ // Input invariants
+ private let pageSize: Int
+
+ // User / UI state
+ @Published var searchText: String = ""
+
+ // Data
+ @Published private(set) var servers: [MCPRegistryServerResponse]
+ @Published private(set) var installedServers: Set = []
+ @Published private(set) var registryMetadata: MCPRegistryServerListMetadata?
+
+ // Loading flags
+ @Published private(set) var isInitialLoading: Bool = false
+ @Published private(set) var isLoadingMore: Bool = false
+ @Published private(set) var isRefreshing: Bool = false
+
+ // Transient presentation state
+ @Published var pendingServer: MCPRegistryServerResponse?
+ @Published var infoSheetServer: MCPRegistryServerResponse?
+ @Published var mcpRegistryEntry: MCPRegistryEntry?
+ @Published private(set) var lastError: Error?
+
+ @AppStorage(\.mcpRegistryBaseURL) var mcpRegistryBaseURL
+ @AppStorage(\.mcpRegistryBaseURLHistory) private var mcpRegistryBaseURLHistory
+
+ // Service integration
+ private let registryService = MCPRegistryService.shared
+
+ init(
+ initialList: MCPRegistryServerList,
+ mcpRegistryEntry: MCPRegistryEntry? = nil,
+ pageSize: Int = 30
+ ) {
+ self.pageSize = pageSize
+ servers = initialList.servers
+ registryMetadata = initialList.metadata
+ self.mcpRegistryEntry = mcpRegistryEntry
+ }
+
+ // MARK: - Derived Data
+
+ var filteredServers: [MCPRegistryServerResponse] {
+ // Only filter for latest official servers; search is handled server-side.
+ // Also ensure we don't surface duplicate stable IDs, which can confuse SwiftUI's diffing.
+ var seen = Set()
+ return servers.compactMap { server in
+ let id = server.stableID
+ if seen.contains(id) { return nil }
+ seen.insert(id)
+ return server
+ }
+ }
+
+ var shouldShowLoadMoreSentinel: Bool {
+ // Show load more sentinel if there's more data available
+ if let next = registryMetadata?.nextCursor, !next.isEmpty {
+ return true
+ }
+ return false
+ }
+
+ func isServerInstalled(serverId: String) -> Bool {
+ // Find the server by ID and check installation status using the service
+ if let server = servers.first(where: { $0.stableID == serverId }) {
+ return registryService.isServerInstalled(server.server)
+ }
+
+ // Fallback to the existing key-based check for backwards compatibility
+ let key = createRegistryServerKey(registryBaseURL: mcpRegistryBaseURL, serverName: serverId)
+ return installedServers.contains(key)
+ }
+
+ func hasNoDeployments(_ server: MCPRegistryServerDetail) -> Bool {
+ return server.remotes?.isEmpty ?? true && server.packages?.isEmpty ?? true
+ }
+
+ // MARK: - User Intents (Updated with Service Integration)
+
+ func requestInstall(_ server: MCPRegistryServerDetail) {
+ Task {
+ await installServer(server)
+ }
+ }
+
+ func requestInstallWithConfiguration(_ server: MCPRegistryServerDetail, configuration: String) {
+ Task {
+ await installServer(server, configuration: configuration)
+ }
+ }
+
+ func installServer(_ server: MCPRegistryServerDetail, configuration: String? = nil) async {
+ do {
+ let installationOption: InstallationOption?
+
+ if let configName = configuration {
+ // Find the specific installation option
+ let options = registryService.getAllInstallationOptions(for: server)
+ installationOption = options.first { option in
+ option.displayName.contains(configName) ||
+ option.description.contains(configName)
+ }
+ } else {
+ installationOption = nil
+ }
+
+ try await registryService.installMCPServer(server, installationOption: installationOption)
+
+ // Refresh installed servers list
+ loadInstalledServers()
+
+ Logger.client.info("Successfully installed MCP Server '\(server.name)'")
+
+ } catch {
+ Logger.client.error("Failed to install server '\(server.name)': \(error)")
+ // TODO: Consider adding error handling UI feedback here
+ }
+ }
+
+ func uninstallServer(_ server: MCPRegistryServerDetail) async {
+ do {
+ try await registryService.uninstallMCPServer(server)
+
+ // Refresh installed servers list
+ loadInstalledServers()
+
+ Logger.client.info("Successfully uninstalled MCP Server '\(server.name)'")
+
+ } catch {
+ Logger.client.error("Failed to uninstall server '\(server.name)': \(error)")
+ // TODO: Consider adding error handling UI feedback here
+ }
+ }
+
+ func refresh() {
+ Task {
+ isRefreshing = true
+ defer { isRefreshing = false }
+
+ // Clear the current server list and search text
+ servers = []
+ registryMetadata = nil
+ searchText = ""
+
+ // Load servers from the base URL with empty query
+ _ = await loadServerList(resetToFirstPage: true)
+ }
+ }
+
+ // Called from Settings view to refresh with optional new registry entry
+ func refreshFromURL(mcpRegistryEntry: MCPRegistryEntry? = nil) async -> Error? {
+ isRefreshing = true
+ defer { isRefreshing = false }
+
+ // Clear the current server list and reset search text when URL changes
+ servers = []
+ registryMetadata = nil
+ searchText = ""
+ self.mcpRegistryEntry = mcpRegistryEntry
+ Logger.client.info("Cleared gallery view model data for refresh")
+
+ // Load servers from the base URL
+ let error = await loadServerList(resetToFirstPage: true)
+
+ // Reload installed servers after fetching new data
+ loadInstalledServers()
+
+ return error
+ }
+
+ func updateData(serverList: MCPRegistryServerList, mcpRegistryEntry: MCPRegistryEntry? = nil) {
+ servers = serverList.servers
+ registryMetadata = serverList.metadata
+ self.mcpRegistryEntry = mcpRegistryEntry
+ searchText = ""
+ loadInstalledServers()
+ Logger.client.info("Updated gallery view model with \(serverList.servers.count) servers and registry entry: \(String(describing: mcpRegistryEntry))")
+ }
+
+ func clearData() {
+ servers = []
+ registryMetadata = nil
+ searchText = ""
+ Logger.client.info("Cleared gallery view model data")
+ }
+
+ /// Refresh the server list in response to a search query change without
+ /// resetting the search text. This is used by the debounced searchable field.
+ func refreshForSearch() {
+ Task {
+ isRefreshing = true
+ defer { isRefreshing = false }
+
+ // Clear current data but keep the active search query
+ servers = []
+ registryMetadata = nil
+
+ _ = await loadServerList(resetToFirstPage: true)
+ }
+ }
+
+ func showInfo(_ server: MCPRegistryServerResponse) {
+ infoSheetServer = server
+ }
+
+ func dismissInfo() {
+ infoSheetServer = nil
+ }
+
+ // MARK: - Data Loading
+
+ func loadMoreIfNeeded() {
+ guard !isLoadingMore,
+ !isInitialLoading,
+ let nextCursor = registryMetadata?.nextCursor,
+ !nextCursor.isEmpty
+ else { return }
+
+ Task {
+ await loadServerList(resetToFirstPage: false)
+ }
+ }
+
+ private func loadServerList(resetToFirstPage: Bool) async -> Error? {
+ if resetToFirstPage {
+ isInitialLoading = true
+ } else {
+ isLoadingMore = true
+ }
+
+ defer {
+ isInitialLoading = false
+ isLoadingMore = false
+ }
+
+ lastError = nil
+
+ do {
+ let service = try getService()
+ let cursor = resetToFirstPage ? nil : registryMetadata?.nextCursor
+
+ let trimmedQuery = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
+
+ let serverList = try await service.listMCPRegistryServers(
+ .init(
+ baseUrl: registryService.getRegistryURL(),
+ cursor: cursor,
+ limit: pageSize,
+ search: trimmedQuery.isEmpty ? nil : trimmedQuery,
+ version: "latest"
+ )
+ )
+
+ if resetToFirstPage {
+ // Replace all servers when refreshing or resetting
+ servers = serverList?.servers ?? []
+ registryMetadata = serverList?.metadata
+ } else {
+ // Append when loading more
+ servers.append(contentsOf: serverList?.servers ?? [])
+ registryMetadata = serverList?.metadata
+ }
+
+ mcpRegistryBaseURLHistory.addToHistory(mcpRegistryBaseURL)
+
+ return nil
+ } catch {
+ Logger.client.error("Failed to load MCP servers: \(error)")
+ lastError = error
+ return error
+ }
+ }
+
+ func loadInstalledServers() {
+ // Clear the set and rebuild it
+ installedServers.removeAll()
+
+ let configFileURL = URL(fileURLWithPath: mcpConfigFilePath)
+ guard FileManager.default.fileExists(atPath: mcpConfigFilePath),
+ let data = try? Data(contentsOf: configFileURL),
+ let currentConfig = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let serversDict = currentConfig["servers"] as? [String: Any] else {
+ return
+ }
+
+ for (_, serverConfig) in serversDict {
+ guard
+ let serverConfigDict = serverConfig as? [String: Any],
+ let metadata = serverConfigDict["x-metadata"] as? [String: Any],
+ let registry = metadata["registry"] as? [String: Any],
+ let api = registry["api"] as? [String: Any],
+ let baseUrl = api["baseUrl"] as? String,
+ let mcpServer = registry["mcpServer"] as? [String: Any],
+ let name = mcpServer["name"] as? String
+ else { continue }
+
+ installedServers.insert(
+ createRegistryServerKey(registryBaseURL: baseUrl, serverName: name)
+ )
+ }
+ }
+
+ private func createRegistryServerKey(registryBaseURL: String, serverName: String) -> String {
+ return registryService.createRegistryServerKey(registryBaseURL: registryBaseURL, serverName: serverName)
+ }
+
+ // MARK: - Installation Options Helper
+
+ func getInstallationOptions(for server: MCPRegistryServerDetail) -> [InstallationOption] {
+ return registryService.getAllInstallationOptions(for: server)
+ }
+}
diff --git a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/ServerInstallationOptionView.swift b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/ServerInstallationOptionView.swift
new file mode 100644
index 00000000..fcc129e4
--- /dev/null
+++ b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/ServerInstallationOptionView.swift
@@ -0,0 +1,170 @@
+import SwiftUI
+import AppKit
+import Foundation
+import SharedUIComponents
+
+struct ServerInstallationOptionView: View {
+ struct Metadata: Identifiable {
+ let id = UUID()
+ let label: String
+ let value: String
+ var monospaced: Bool = false
+ var isLink: Bool = false
+ }
+
+ let title: String
+ let iconSystemName: String
+ let versionTag: String?
+ let metadata: [Metadata]
+
+ // State/control flags passed from parent
+ let isExpanded: Bool
+ let isInstalled: Bool
+ let isInstalling: Bool
+ let showUninstall: Bool
+
+ // Layout constants
+ let labelColumnWidth: CGFloat
+
+ // Behavior closures supplied by parent
+ let onToggleExpand: () -> Void
+ let onInstall: () -> Void
+ let onUninstall: () -> Void
+
+ // Optional configuration JSON (already generated by parent) shown when expanded
+ let config: [String: Any]?
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ header
+ if isExpanded, let config {
+ configSection(config)
+ .transition(.opacity.combined(with: .scale(scale: 1, anchor: .top)))
+ }
+ }
+ .padding(16)
+ .background(
+ RoundedRectangle(cornerRadius: 8)
+ .fill(Color(nsColor: .controlBackgroundColor))
+ .overlay(
+ RoundedRectangle(cornerRadius: 8)
+ .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
+ )
+ )
+ .animation(.easeInOut(duration: 0.2), value: isExpanded)
+ }
+
+ private var header: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ HStack(alignment: .center, spacing: 8) {
+ Label(title, systemImage: iconSystemName)
+ .font(.system(size: 14, weight: .medium))
+
+ if let versionTag {
+ Text(versionTag)
+ .font(.system(size: 12, design: .monospaced))
+ .padding(.horizontal, 8)
+ .padding(.vertical, 2)
+ .background(Capsule().fill(Color.green.opacity(0.15)))
+ }
+
+ Spacer()
+
+ Button(isExpanded ? "Hide" : "Preview") { onToggleExpand() }
+ .buttonStyle(.bordered)
+ .help(isExpanded ? "Hide configuration details" : "Preview configuration details")
+
+ if showUninstall {
+ Button("Uninstall") { onUninstall() }
+ .buttonStyle(DestructiveButtonStyle())
+ .help("Uninstall this installed option")
+ } else {
+ Button(action: onInstall) {
+ if isInstalling {
+ ProgressView().controlSize(.mini)
+ } else {
+ Text("Install")
+ }
+ }
+ .disabled(isInstalling)
+ .buttonStyle(.borderedProminent)
+ .help("Install this server using the selected option")
+ }
+ }
+
+ // Metadata rows
+ Group {
+ ForEach(metadata) { item in
+ HStack(spacing: 6) {
+ Text(item.label)
+ .font(.system(size: 12, weight: .medium))
+ .foregroundColor(.secondary)
+ .frame(width: labelColumnWidth, alignment: .leading)
+
+ if item.isLink, let url = URL(string: item.value) {
+ Link(item.value, destination: url)
+ .font(.system(size: 12, design: item.monospaced ? .monospaced : .default))
+ .foregroundColor(.primary)
+ .textSelection(.enabled)
+ } else {
+ Text(item.value)
+ .font(.system(size: 12, design: item.monospaced ? .monospaced : .default))
+ .foregroundColor(.primary)
+ .textSelection(.enabled)
+ }
+ }
+ }
+ }
+ .padding(.top, 6)
+ }
+ }
+
+ private func configSection(_ config: [String: Any]) -> some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Divider().padding(.vertical, 4)
+ HStack {
+ Text("Server Configuration")
+ .font(.system(size: 13, weight: .medium))
+ Spacer()
+ }
+ configView(config)
+ }
+ }
+
+ @ViewBuilder
+ private func configView(_ config: [String: Any]) -> some View {
+ ZStack(alignment: .topTrailing) {
+ VStack(alignment: .leading, spacing: 8) {
+ Text(formatConfigAsJSON(config))
+ .font(.system(.callout, design: .monospaced))
+ .textSelection(.enabled)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .fixedSize(horizontal: false, vertical: true)
+ .padding(.bottom, 2)
+ }
+ .padding(12)
+
+ CopyButton {
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(formatConfigAsJSON(config), forType: .string)
+ }
+ .padding(6)
+ .help("Copy configuration to clipboard")
+ }
+ .background(
+ RoundedRectangle(cornerRadius: 6)
+ .fill(Color(nsColor: .textBackgroundColor).opacity(0.5))
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: 6)
+ .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
+ )
+ }
+
+ private func formatConfigAsJSON(_ config: [String: Any]) -> String {
+ do {
+ let data = try JSONSerialization.data(withJSONObject: config, options: [.prettyPrinted, .sortedKeys])
+ return String(data: data, encoding: .utf8) ?? "{}"
+ } catch { return "{}" }
+ }
+}
diff --git a/Core/Sources/HostApp/ToolsSettings/MCPServerToolsSection.swift b/Core/Sources/HostApp/ToolsSettings/MCPServerToolsSection.swift
index f9c45687..47abc27a 100644
--- a/Core/Sources/HostApp/ToolsSettings/MCPServerToolsSection.swift
+++ b/Core/Sources/HostApp/ToolsSettings/MCPServerToolsSection.swift
@@ -3,37 +3,119 @@ import Persist
import GitHubCopilotService
import Client
import Logger
+import Foundation
+import SharedUIComponents
+import ConversationServiceProvider
/// Section for a single server's tools
struct MCPServerToolsSection: View {
let serverTools: MCPServerToolsCollection
@Binding var isServerEnabled: Bool
var forceExpand: Bool = false
+ var isInteractionAllowed: Bool = true
+ @Binding var modes: [ConversationMode]
+ @Binding var selectedMode: ConversationMode
@State private var toolEnabledStates: [String: Bool] = [:]
@State private var isExpanded: Bool = true
+ @State private var checkboxMixedState: CheckboxMixedState = .off
private var originalServerName: String { serverTools.name }
+ @State private var isShowingDeleteConfirmation: Bool = false
+
private var serverToggleLabel: some View {
HStack(spacing: 8) {
- Text("MCP Server: \(serverTools.name)").fontWeight(.medium)
- if serverTools.status == .error {
+ Text("MCP Server: \(serverTools.name)")
+ .fontWeight(.medium)
+ .foregroundStyle(
+ serverTools.status == .running ? .primary : .tertiary
+ )
+ if serverTools.status == .error || serverTools.status == .blocked {
let message = extractErrorMessage(serverTools.error?.description ?? "")
- Badge(text: message, level: .danger, icon: "xmark.circle.fill")
+ if serverTools.status == .error {
+ Badge(
+ attributedText: createErrorMessage(message),
+ level: .danger,
+ icon: "xmark.circle.fill"
+ )
+ .environment((\.openURL), OpenURLAction { url in
+ if url.absoluteString == "mcp://open-config" {
+ openMCPConfigFile()
+ return .handled
+ }
+ return .systemAction
+ })
+ } else if serverTools.status == .blocked {
+ Badge(text: serverTools.registryInfo ?? "Blocked", level: .warning, icon: "exclamationmark.triangle.fill")
+ }
+ } else if let registryInfo = serverTools.registryInfo {
+ Text(registryInfo)
+ .foregroundStyle(.secondary)
+ .font(.system(size: 11))
}
- Spacer()
+ }
+ }
+
+ private func openMCPConfigFile() {
+ let url = URL(fileURLWithPath: mcpConfigFilePath)
+ NSWorkspace.shared.open(url)
+ }
+
+ private func createErrorMessage(_ baseMessage: String) -> AttributedString {
+ if hasServerConfigPlaceholders() {
+ let prefix = baseMessage.isEmpty ? "" : baseMessage + ". "
+ var attributedString = AttributedString(prefix + "You may need to update placeholders in ")
+
+ var mcpLink = AttributedString("mcp.json")
+ mcpLink.link = URL(string: "mcp://open-config")
+ mcpLink.underlineStyle = .single
+
+ attributedString.append(mcpLink)
+ attributedString.append(AttributedString("."))
+
+ return attributedString
+ } else {
+ return AttributedString(baseMessage)
}
}
private var serverToggle: some View {
- Toggle(isOn: Binding(
- get: { isServerEnabled },
- set: { updateAllToolsStatus(enabled: $0) }
- )) {
+ HStack(spacing: 8) {
+ MixedStateCheckbox(
+ title: "",
+ font: .systemFont(ofSize: 13),
+ state: $checkboxMixedState
+ ) {
+ switch checkboxMixedState {
+ case .off, .mixed:
+ // Enable all tools
+ updateAllToolsStatus(enabled: true)
+ case .on:
+ // Disable all tools
+ updateAllToolsStatus(enabled: false)
+ }
+ updateMixedState()
+ }
+ .disabled(serverTools.status == .error || serverTools.status == .blocked || !isInteractionAllowed)
+
serverToggleLabel
+ .contentShape(Rectangle())
+ .onTapGesture {
+ if serverTools.status != .error && serverTools.status != .blocked {
+ withAnimation {
+ isExpanded.toggle()
+ }
+ }
+ }
+
+ Spacer()
+
+ Button(action: { isShowingDeleteConfirmation = true }) {
+ Image(systemName: "trash").font(.system(size: 12))
+ }
+ .buttonStyle(HoverButtonStyle())
+ .padding(-4)
}
- .toggleStyle(.checkbox)
.padding(.leading, 4)
- .disabled(serverTools.status == .error)
}
private var divider: some View {
@@ -53,6 +135,7 @@ struct MCPServerToolsSection: View {
toolStatus: tool._status,
isServerEnabled: isServerEnabled,
isToolEnabled: toolBindingFor(tool),
+ isInteractionAllowed: isInteractionAllowed,
onToolToggleChanged: { handleToolToggleChange(tool: tool, isEnabled: $0) }
)
.padding(.leading, 36)
@@ -60,6 +143,7 @@ struct MCPServerToolsSection: View {
}
.onChange(of: serverTools) { newValue in
initializeToolStates(server: newValue)
+ updateMixedState()
}
}
@@ -67,10 +151,12 @@ struct MCPServerToolsSection: View {
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Conditional view rendering based on error state
- if serverTools.status == .error {
+ if serverTools.status == .error || serverTools.status == .blocked {
// No disclosure group for error state
VStack(spacing: 0) {
- serverToggle.padding(.leading, 12)
+ serverToggle
+ .padding(.leading, 11)
+ .padding(.trailing, 4)
divider.padding(.top, 4)
}
} else {
@@ -82,6 +168,7 @@ struct MCPServerToolsSection: View {
}
.onAppear {
initializeToolStates(server: serverTools)
+ updateMixedState()
if forceExpand {
isExpanded = true
}
@@ -91,12 +178,63 @@ struct MCPServerToolsSection: View {
isExpanded = true
}
}
+ .onChange(of: selectedMode) { _ in
+ toolEnabledStates = [:]
+ initializeToolStates(server: serverTools)
+ updateMixedState()
+ }
+ .onChange(of: selectedMode.customTools) { _ in
+ Task {
+ await reloadModesAndUpdateStates()
+ }
+ }
+ .onReceive(DistributedNotificationCenter.default().publisher(for: .gitHubCopilotCustomAgentToolsDidChange)) { _ in
+ Logger.client.info("Custom agent tools change notification received in MCPServerToolsSection")
+ if !selectedMode.isDefaultAgent {
+ Task {
+ await reloadModesAndUpdateStates()
+ }
+ }
+ }
if !isExpanded {
divider
}
}
}
+ .confirmationDialog(
+ "Do you want to delete '\(serverTools.name)'?",
+ isPresented: $isShowingDeleteConfirmation
+ ) {
+ Button("Cancel", role: .cancel) { }
+ Button("Delete", role: .destructive) { deleteServerConfig() }
+ }
+ }
+
+ private func deleteServerConfig() {
+ let fileURL = URL(fileURLWithPath: mcpConfigFilePath)
+
+ guard let data = try? Data(contentsOf: fileURL) else {
+ Logger.client.error("Failed to read mcp.json when deleting server config.")
+ return
+ }
+
+ guard var rootObject = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else {
+ Logger.client.error("Failed to parse mcp.json when deleting server config.")
+ return
+ }
+
+ if var servers = rootObject["servers"] as? [String: Any] {
+ servers.removeValue(forKey: serverTools.name)
+ rootObject["servers"] = servers
+ }
+
+ do {
+ let newData = try JSONSerialization.data(withJSONObject: rootObject, options: [.prettyPrinted, .sortedKeys])
+ try newData.write(to: fileURL)
+ } catch {
+ Logger.client.error("Failed to write updated mcp.json when deleting server config: \(error.localizedDescription)")
+ }
}
private func extractErrorMessage(_ description: String) -> String {
@@ -108,18 +246,59 @@ struct MCPServerToolsSection: View {
let end = description.index(stackRange.lowerBound, offsetBy: 0)
return description[start.. Bool {
+ let configFileURL = URL(fileURLWithPath: mcpConfigFilePath)
+
+ guard FileManager.default.fileExists(atPath: mcpConfigFilePath),
+ let data = try? Data(contentsOf: configFileURL),
+ let jsonObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let servers = jsonObject["servers"] as? [String: Any],
+ let serverConfig = servers[serverTools.name] else {
+ return false
+ }
+
+ // Convert server config to JSON string
+ guard let serverData = try? JSONSerialization.data(withJSONObject: serverConfig, options: []),
+ let serverConfigString = String(data: serverData, encoding: .utf8) else {
+ return false
+ }
+
+ // Check for placeholder patterns ending with }"
+ // Matches: "{PLACEHOLDER}", "${PLACEHOLDER}", "key={PLACEHOLDER}", "key=${PLACEHOLDER}", "${prefix:PLACEHOLDER}"
+ let placeholderPattern = "\"([a-zA-Z0-9_]+=)?\\$?\\{[a-zA-Z0-9_:\\-\\.]+\\}\""
+
+ guard let regex = try? NSRegularExpression(pattern: placeholderPattern, options: []) else {
+ return false
+ }
+
+ let range = NSRange(serverConfigString.startIndex.. Binding {
Binding(
- get: { toolEnabledStates[tool.name] ?? (tool._status == .enabled) },
+ get: {
+ toolEnabledStates[tool.name] ?? isToolEnabledInMode(tool.name, currentStatus: tool._status)
+ },
set: { toolEnabledStates[tool.name] = $0 }
)
}
@@ -140,6 +321,9 @@ struct MCPServerToolsSection: View {
// Update server state based on tool states
updateServerState()
+ // Update mixed state
+ updateMixedState()
+
// Update only this specific tool status
updateToolStatus(tool: tool, isEnabled: isEnabled)
}
@@ -178,25 +362,98 @@ struct MCPServerToolsSection: View {
// Create status update for all tools
let serverUpdate = UpdateMCPToolsStatusServerCollection(
name: serverTools.name,
- tools: allServerTools.map {
+ tools: allServerTools.map {
UpdatedMCPToolsStatus(name: $0.name, status: enabled ? .enabled : .disabled)
}
)
updateMCPStatus([serverUpdate])
}
+
+ private func updateMixedState() {
+ let allServerTools = CopilotMCPToolManagerObservable.shared.availableMCPServerTools
+ .first(where: { $0.name == originalServerName })?.tools ?? serverTools.tools
+
+ let enabledCount = allServerTools.filter { tool in
+ toolEnabledStates[tool.name] ?? (tool._status == .enabled)
+ }.count
+
+ let totalCount = allServerTools.count
+
+ if enabledCount == 0 {
+ checkboxMixedState = .off
+ } else if enabledCount == totalCount {
+ checkboxMixedState = .on
+ } else {
+ checkboxMixedState = .mixed
+ }
+ }
private func updateMCPStatus(_ serverUpdates: [UpdateMCPToolsStatusServerCollection]) {
- // Update status in AppState and CopilotMCPToolManager
- AppState.shared.updateMCPToolsStatus(serverUpdates)
-
+ let isDefaultAgentMode = selectedMode.isDefaultAgent
Task {
do {
let service = try getService()
- try await service.updateMCPServerToolsStatus(serverUpdates)
+
+ if !isDefaultAgentMode {
+ let chatMode = selectedMode.kind
+ let customChatModeId = selectedMode.isBuiltIn == false ? selectedMode.id : nil
+ let workspaceFolders = await getWorkspaceFolders()
+
+ try await service
+ .updateMCPServerToolsStatus(
+ serverUpdates,
+ chatAgentMode: chatMode,
+ customChatModeId: customChatModeId,
+ workspaceFolders: workspaceFolders
+ )
+ } else {
+ try await service.updateMCPServerToolsStatus(serverUpdates)
+ }
} catch {
Logger.client.error("Failed to update MCP status: \(error.localizedDescription)")
}
}
}
+
+ @MainActor
+ private func reloadModesAndUpdateStates() async {
+ do {
+ let service = try getService()
+ let workspaceFolders = await getWorkspaceFolders()
+ if let fetchedModes = try await service.getModes(workspaceFolders: workspaceFolders) {
+ modes = fetchedModes.filter { $0.kind == .Agent }
+
+ if let updatedMode = modes.first(where: { $0.id == selectedMode.id }) {
+ selectedMode = updatedMode
+
+ let allServerTools = CopilotMCPToolManagerObservable.shared.availableMCPServerTools
+ .first(where: { $0.name == originalServerName })?.tools ?? serverTools.tools
+
+ for tool in allServerTools {
+ let toolName = "\(serverTools.name)/\(tool.name)"
+ if let customTools = updatedMode.customTools {
+ toolEnabledStates[tool.name] = customTools.contains(toolName)
+ } else {
+ toolEnabledStates[tool.name] = false
+ }
+ }
+
+ updateMixedState()
+ updateServerState()
+ }
+ }
+ } catch {
+ Logger.client.error("Failed to reload modes: \(error.localizedDescription)")
+ }
+ }
+
+ private func isToolEnabledInMode(_ toolName: String, currentStatus: ToolStatus) -> Bool {
+ let configurationKey = "\(serverTools.name)/\(toolName)"
+ return AgentModeToolHelpers.isToolEnabledInMode(
+ configurationKey: configurationKey,
+ currentStatus: currentStatus,
+ selectedMode: selectedMode
+ )
+ }
}
diff --git a/Core/Sources/HostApp/ToolsSettings/MCPToolsListContainerView.swift b/Core/Sources/HostApp/ToolsSettings/MCPToolsListContainerView.swift
index 27f2d6cb..ecf30952 100644
--- a/Core/Sources/HostApp/ToolsSettings/MCPToolsListContainerView.swift
+++ b/Core/Sources/HostApp/ToolsSettings/MCPToolsListContainerView.swift
@@ -1,5 +1,6 @@
import SwiftUI
import GitHubCopilotService
+import ConversationServiceProvider
/// Main list view containing all the tools
struct MCPToolsListContainerView: View {
@@ -7,6 +8,9 @@ struct MCPToolsListContainerView: View {
@Binding var serverToggleStates: [String: Bool]
let searchKey: String
let expandedServerNames: Set
+ var isInteractionAllowed: Bool = true
+ @Binding var modes: [ConversationMode]
+ @Binding var selectedMode: ConversationMode
var body: some View {
VStack(alignment: .leading, spacing: 4) {
@@ -14,11 +18,15 @@ struct MCPToolsListContainerView: View {
MCPServerToolsSection(
serverTools: serverTools,
isServerEnabled: serverToggleBinding(for: serverTools.name),
- forceExpand: expandedServerNames.contains(serverTools.name) && !searchKey.isEmpty
+ forceExpand: expandedServerNames.contains(serverTools.name) && !searchKey.isEmpty,
+ isInteractionAllowed: isInteractionAllowed,
+ modes: $modes,
+ selectedMode: $selectedMode
)
}
}
.padding(.vertical, 4)
+ .id(selectedMode.id)
}
private func serverToggleBinding(for serverName: String) -> Binding {
diff --git a/Core/Sources/HostApp/ToolsSettings/MCPToolsListView.swift b/Core/Sources/HostApp/ToolsSettings/MCPToolsListView.swift
index 2cb6f530..ba8e1b4f 100644
--- a/Core/Sources/HostApp/ToolsSettings/MCPToolsListView.swift
+++ b/Core/Sources/HostApp/ToolsSettings/MCPToolsListView.swift
@@ -2,23 +2,35 @@ import Combine
import GitHubCopilotService
import Persist
import SwiftUI
+import SharedUIComponents
+import ConversationServiceProvider
struct MCPToolsListView: View {
@ObservedObject private var mcpToolManager = CopilotMCPToolManagerObservable.shared
@State private var serverToggleStates: [String: Bool] = [:]
@State private var isSearchBarVisible: Bool = false
@State private var searchText: String = ""
+ @State private var modes: [ConversationMode] = []
+ @Binding var selectedMode: ConversationMode
+ let isCustomAgentEnabled: Bool
var body: some View {
VStack(alignment: .leading, spacing: 8) {
GroupBox(
label:
- HStack(alignment: .center) {
- Text("Available MCP Tools").fontWeight(.bold)
- Spacer()
- SearchBar(isVisible: $isSearchBarVisible, text: $searchText)
+ VStack(alignment: .leading, spacing: 8) {
+ HStack(alignment: .center) {
+ Text("Available MCP Tools").fontWeight(.bold)
+ if isCustomAgentEnabled {
+ AgentModeDropdown(modes: $modes, selectedMode: $selectedMode)
+ }
+ Spacer()
+ CollapsibleSearchField(searchText: $searchText, isExpanded: $isSearchBarVisible)
+ }
+ .clipped()
+
+ AgentModeDescriptionView(selectedMode: selectedMode, isLoadingMode: false)
}
- .clipped()
) {
let filteredServerTools = filteredMCPServerTools()
if filteredServerTools.isEmpty {
@@ -28,7 +40,10 @@ struct MCPToolsListView: View {
mcpServerTools: filteredServerTools,
serverToggleStates: $serverToggleStates,
searchKey: searchText,
- expandedServerNames: expandedServerNames(filteredServerTools: filteredServerTools)
+ expandedServerNames: expandedServerNames(filteredServerTools: filteredServerTools),
+ isInteractionAllowed: isInteractionAllowed(),
+ modes: $modes,
+ selectedMode: $selectedMode
)
}
}
@@ -38,6 +53,9 @@ struct MCPToolsListView: View {
.onChange(of: mcpToolManager.availableMCPServerTools) { _ in
updateServerToggleStates()
}
+ .onChange(of: selectedMode) { _ in
+ updateServerToggleStates()
+ }
}
private func updateServerToggleStates() {
@@ -73,6 +91,10 @@ struct MCPToolsListView: View {
// Expand all groups that have at least one tool in the filtered list
Set(filteredServerTools.map { $0.name })
}
+
+ private func isInteractionAllowed() -> Bool {
+ return AgentModeToolHelpers.isInteractionAllowed(selectedMode: selectedMode)
+ }
}
/// Empty state view when no tools are available
diff --git a/Core/Sources/HostApp/ToolsSettings/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/HostApp/ToolsSettings/ToolRowView.swift b/Core/Sources/HostApp/ToolsSettings/ToolRowView.swift
index 66a0ab81..d8df5965 100644
--- a/Core/Sources/HostApp/ToolsSettings/ToolRowView.swift
+++ b/Core/Sources/HostApp/ToolsSettings/ToolRowView.swift
@@ -8,13 +8,17 @@ struct ToolRow: View {
let toolStatus: ToolStatus
let isServerEnabled: Bool
@Binding var isToolEnabled: Bool
+ var isInteractionAllowed: Bool = true
let onToolToggleChanged: (Bool) -> Void
var body: some View {
HStack(alignment: .center) {
Toggle(isOn: Binding(
get: { isToolEnabled },
- set: { onToolToggleChanged($0) }
+ set: { newValue in
+ isToolEnabled = newValue
+ onToolToggleChanged(newValue)
+ }
)) {
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .center, spacing: 8) {
@@ -32,9 +36,8 @@ struct ToolRow: View {
Divider().padding(.vertical, 4)
}
}
+ .disabled(!isInteractionAllowed)
}
.padding(.vertical, 0)
- .onChange(of: toolStatus) { isToolEnabled = $0 == .enabled }
- .onChange(of: isServerEnabled) { if !$0 { isToolEnabled = false } }
}
}
diff --git a/Core/Sources/KeyBindingManager/KeyBindingManager.swift b/Core/Sources/KeyBindingManager/KeyBindingManager.swift
index 2fcf67fa..e0a22188 100644
--- a/Core/Sources/KeyBindingManager/KeyBindingManager.swift
+++ b/Core/Sources/KeyBindingManager/KeyBindingManager.swift
@@ -5,16 +5,24 @@ public final class KeyBindingManager {
public init(
workspacePool: WorkspacePool,
acceptSuggestion: @escaping () -> Void,
+ acceptNESSuggestion: @escaping () -> Void,
expandSuggestion: @escaping () -> Void,
collapseSuggestion: @escaping () -> Void,
- dismissSuggestion: @escaping () -> Void
+ dismissSuggestion: @escaping () -> Void,
+ rejectNESSuggestion: @escaping () -> Void,
+ goToNextEditSuggestion: @escaping () -> Void,
+ isNESPanelOutOfFrame: @escaping () -> Bool
) {
tabToAcceptSuggestion = .init(
workspacePool: workspacePool,
acceptSuggestion: acceptSuggestion,
- dismissSuggestion: dismissSuggestion,
+ acceptNESSuggestion: acceptNESSuggestion,
+ dismissSuggestion: dismissSuggestion,
expandSuggestion: expandSuggestion,
- collapseSuggestion: collapseSuggestion
+ collapseSuggestion: collapseSuggestion,
+ rejectNESSuggestion: rejectNESSuggestion,
+ goToNextEditSuggestion: goToNextEditSuggestion,
+ isNESPanelOutOfFrame: isNESPanelOutOfFrame
)
}
diff --git a/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift b/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift
index f2d4c147..4ebfe1a2 100644
--- a/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift
+++ b/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift
@@ -8,6 +8,7 @@ import SuggestionBasic
import UserDefaultsObserver
import Workspace
import XcodeInspector
+import SuggestionWidget
final class TabToAcceptSuggestion {
let hook: CGEventHookType = CGEventHook(eventsOfInterest: [.keyDown]) { message in
@@ -16,9 +17,13 @@ final class TabToAcceptSuggestion {
let workspacePool: WorkspacePool
let acceptSuggestion: () -> Void
+ let acceptNESSuggestion: () -> Void
let expandSuggestion: () -> Void
let collapseSuggestion: () -> Void
let dismissSuggestion: () -> Void
+ let rejectNESSuggestion: () -> Void
+ let goToNextEditSuggestion: () -> Void
+ let isNESPanelOutOfFrame: () -> Bool
private var modifierEventMonitor: Any?
private let userDefaultsObserver = UserDefaultsObserver(
object: UserDefaults.shared, forKeyPaths: [
@@ -47,16 +52,24 @@ final class TabToAcceptSuggestion {
init(
workspacePool: WorkspacePool,
acceptSuggestion: @escaping () -> Void,
+ acceptNESSuggestion: @escaping () -> Void,
dismissSuggestion: @escaping () -> Void,
expandSuggestion: @escaping () -> Void,
- collapseSuggestion: @escaping () -> Void
+ collapseSuggestion: @escaping () -> Void,
+ rejectNESSuggestion: @escaping () -> Void,
+ goToNextEditSuggestion: @escaping () -> Void,
+ isNESPanelOutOfFrame: @escaping () -> Bool
) {
_ = ThreadSafeAccessToXcodeInspector.shared
self.workspacePool = workspacePool
self.acceptSuggestion = acceptSuggestion
+ self.acceptNESSuggestion = acceptNESSuggestion
self.dismissSuggestion = dismissSuggestion
+ self.rejectNESSuggestion = rejectNESSuggestion
self.expandSuggestion = expandSuggestion
self.collapseSuggestion = collapseSuggestion
+ self.goToNextEditSuggestion = goToNextEditSuggestion
+ self.isNESPanelOutOfFrame = isNESPanelOutOfFrame
hook.add(
.init(
@@ -121,18 +134,48 @@ final class TabToAcceptSuggestion {
}
func handleEvent(_ event: CGEvent) -> CGEventManipulation.Result {
- let (accept, reason) = Self.shouldAcceptSuggestion(
- event: event,
- workspacePool: workspacePool,
- xcodeInspector: ThreadSafeAccessToXcodeInspector.shared
- )
- if let reason = reason {
- Logger.service.debug("TabToAcceptSuggestion: \(accept ? "" : "not") accepting due to: \(reason)")
- }
- if accept {
- acceptSuggestion()
- return .discarded
+ let keycode = Int(event.getIntegerValueField(.keyboardEventKeycode))
+ let tab = 48
+ let escape = 53
+
+ if keycode == tab {
+ let (accept, reason, codeSuggestionType) = Self.shouldAcceptSuggestion(
+ event: event,
+ workspacePool: workspacePool,
+ xcodeInspector: ThreadSafeAccessToXcodeInspector.shared
+ )
+ if let reason = reason {
+ Logger.service.debug("TabToAcceptSuggestion: \(accept ? "" : "not") accepting due to: \(reason)")
+ }
+ if accept, let codeSuggestionType {
+ switch codeSuggestionType {
+ case .codeCompletion:
+ acceptSuggestion()
+ case .nes:
+ if isNESPanelOutOfFrame() {
+ goToNextEditSuggestion()
+ } else {
+ acceptNESSuggestion()
+ }
+ }
+ return .discarded
+ }
+ return .unchanged
+ } else if keycode == escape {
+ let (shouldReject, reason) = Self.shouldRejectNESSuggestion(
+ event: event,
+ workspacePool: workspacePool,
+ xcodeInspector: ThreadSafeAccessToXcodeInspector.shared
+ )
+ if let reason = reason {
+ Logger.service.debug("ShouldRejectNESSuggestion: \(shouldReject ? "" : "not") rejecting due to: \(reason)")
+ }
+ if shouldReject {
+ rejectNESSuggestion()
+ return .discarded
+ }
}
+
return .unchanged
}
@@ -146,36 +189,93 @@ final class TabToAcceptSuggestion {
}
extension TabToAcceptSuggestion {
+
+ enum SuggestionAction {
+ case acceptSuggestion, rejectNESSuggestion
+ }
+
/// Returns whether a given keyboard event should be intercepted and trigger
/// accepting a suggestion.
static func shouldAcceptSuggestion(
event: CGEvent,
workspacePool: WorkspacePool,
xcodeInspector: ThreadSafeAccessToXcodeInspectorProtocol
+ ) -> (accept: Bool, reason: String?, codeSuggestionType: CodeSuggestionType?) {
+ let (isValidEvent, eventReason) = Self.validateEvent(event)
+ guard isValidEvent else { return (false, eventReason, nil) }
+
+ let (isValidFilespace, filespaceReason, codeSuggestionType) = Self.validateFilespace(
+ event,
+ workspacePool: workspacePool,
+ xcodeInspector: xcodeInspector,
+ suggestionAction: .acceptSuggestion
+ )
+ guard isValidFilespace else { return (false, filespaceReason, nil) }
+
+ return (true, nil, codeSuggestionType)
+ }
+
+ static func shouldRejectNESSuggestion(
+ event: CGEvent,
+ workspacePool: WorkspacePool,
+ xcodeInspector: ThreadSafeAccessToXcodeInspectorProtocol
) -> (accept: Bool, reason: String?) {
- let keycode = Int(event.getIntegerValueField(.keyboardEventKeycode))
- let tab = 48
- guard keycode == tab else { return (false, nil) }
+ let (isValidEvent, eventReason) = Self.validateEvent(event)
+ guard isValidEvent else { return (false, eventReason) }
+
+ let (isValidFilespace, filespaceReason, _) = Self.validateFilespace(
+ event,
+ workspacePool: workspacePool,
+ xcodeInspector: xcodeInspector,
+ suggestionAction: .rejectNESSuggestion
+ )
+ guard isValidFilespace else { return (false, filespaceReason) }
+
+ return (true, nil)
+ }
+
+ static private func validateEvent(_ event: CGEvent) -> (Bool, String?) {
if event.flags.contains(.maskHelp) { return (false, nil) }
if event.flags.contains(.maskShift) { return (false, nil) }
if event.flags.contains(.maskControl) { return (false, nil) }
if event.flags.contains(.maskCommand) { return (false, nil) }
+
+ return (true, nil)
+ }
+
+ static private func validateFilespace(
+ _ event: CGEvent,
+ workspacePool: WorkspacePool,
+ xcodeInspector: ThreadSafeAccessToXcodeInspectorProtocol,
+ suggestionAction: SuggestionAction
+ ) -> (Bool, String?, CodeSuggestionType?) {
guard xcodeInspector.hasActiveXcode else {
- return (false, "No active Xcode")
+ return (false, "No active Xcode", nil)
}
guard xcodeInspector.hasFocusedEditor else {
- return (false, "No focused editor")
+ return (false, "No focused editor", nil)
}
guard let fileURL = xcodeInspector.activeDocumentURL else {
- return (false, "No active document")
+ return (false, "No active document", nil)
}
guard let filespace = workspacePool.fetchFilespaceIfExisted(fileURL: fileURL) else {
- return (false, "No filespace")
+ return (false, "No filespace", nil)
}
- if filespace.presentingSuggestion == nil {
- return (false, "No suggestion")
+
+ let codeSuggestionType: CodeSuggestionType? = {
+ if let _ = filespace.presentingSuggestion { return .codeCompletion }
+ if let _ = filespace.presentingNESSuggestion { return .nes }
+ return nil
+ }()
+ guard let codeSuggestionType = codeSuggestionType else {
+ return (false, "No suggestion", nil)
}
- return (true, nil)
+
+ if suggestionAction == .rejectNESSuggestion, codeSuggestionType != .nes {
+ return (false, "Invalid NES suggestion", nil)
+ }
+
+ return (true, nil, codeSuggestionType)
}
}
diff --git a/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift b/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift
index c311439d..1cee8bf2 100644
--- a/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift
+++ b/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift
@@ -24,17 +24,10 @@ public struct LaunchAgentManager {
}
public func setupLaunchAgentForTheFirstTimeIfNeeded() async throws {
- if #available(macOS 13, *) {
- await removeObsoleteLaunchAgent()
- try await setupLaunchAgent()
- } else {
- guard !FileManager.default.fileExists(atPath: launchAgentPath) else { return }
- try await setupLaunchAgent()
- await removeObsoleteLaunchAgent()
- }
+ await removeObsoleteLaunchAgent()
+ try await setupLaunchAgent()
}
- @available(macOS 13.0, *)
public func isBackgroundPermissionGranted() async -> Bool {
// On macOS 13+, check SMAppService status
let bridgeLaunchAgent = SMAppService.agent(plistName: "bridgeLaunchAgent.plist")
@@ -43,87 +36,41 @@ public struct LaunchAgentManager {
}
public func setupLaunchAgent() async throws {
- if #available(macOS 13, *) {
- Logger.client.info("Registering bridge launch agent")
- let bridgeLaunchAgent = SMAppService.agent(plistName: "bridgeLaunchAgent.plist")
- try bridgeLaunchAgent.register()
- } else {
- Logger.client.info("Creating and loading bridge launch agent")
- let content = """
-
-
-
-
- Label
- \(serviceIdentifier)
- Program
- \(executablePath)
- MachServices
-
- \(serviceIdentifier)
-
-
- AssociatedBundleIdentifiers
-
- \(bundleIdentifier)
- \(serviceIdentifier)
-
-
-
- """
- if !FileManager.default.fileExists(atPath: launchAgentDirURL.path) {
- try FileManager.default.createDirectory(
- at: launchAgentDirURL,
- withIntermediateDirectories: false
- )
- }
- FileManager.default.createFile(
- atPath: launchAgentPath,
- contents: content.data(using: .utf8)
- )
- try await launchctl("load", launchAgentPath)
- }
+ Logger.client.info("Registering bridge launch agent")
+ let bridgeLaunchAgent = SMAppService.agent(plistName: "bridgeLaunchAgent.plist")
+ try bridgeLaunchAgent.register()
let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String
UserDefaults.standard.set(buildNumber, forKey: lastLaunchAgentVersionKey)
}
public func removeLaunchAgent() async throws {
- if #available(macOS 13, *) {
- Logger.client.info("Unregistering bridge launch agent")
- let bridgeLaunchAgent = SMAppService.agent(plistName: "bridgeLaunchAgent.plist")
- try await bridgeLaunchAgent.unregister()
- } else {
- Logger.client.info("Unloading and removing bridge launch agent")
- try await launchctl("unload", launchAgentPath)
- try FileManager.default.removeItem(atPath: launchAgentPath)
- }
+ Logger.client.info("Unregistering bridge launch agent")
+ let bridgeLaunchAgent = SMAppService.agent(plistName: "bridgeLaunchAgent.plist")
+ try await bridgeLaunchAgent.unregister()
}
public func reloadLaunchAgent() async throws {
- if #unavailable(macOS 13) {
- Logger.client.info("Reloading bridge launch agent")
- try await helper("reload-launch-agent", "--service-identifier", serviceIdentifier)
- }
+ // No-op: macOS 13+ uses SMAppService which doesn't need manual reload
}
public func removeObsoleteLaunchAgent() async {
- if #available(macOS 13, *) {
- let path = launchAgentPath
- if FileManager.default.fileExists(atPath: path) {
- Logger.client.info("Unloading and removing old bridge launch agent")
- try? await launchctl("unload", path)
- try? FileManager.default.removeItem(atPath: path)
- }
- } else {
- let path = launchAgentPath.replacingOccurrences(
- of: "ExtensionService",
- with: "XPCService"
- )
- if FileManager.default.fileExists(atPath: path) {
- Logger.client.info("Removing old bridge launch agent plist")
- try? FileManager.default.removeItem(atPath: path)
- }
+ let path = launchAgentPath
+ if FileManager.default.fileExists(atPath: path) {
+ Logger.client.info("Unloading and removing old bridge launch agent")
+ try? await launchctl("unload", path)
+ try? FileManager.default.removeItem(atPath: path)
+ }
+
+ // Also remove legacy plist that used "XPCService" instead of "ExtensionService"
+ let legacyIdentifier = serviceIdentifier
+ .replacingOccurrences(of: "ExtensionService", with: "XPCService")
+ let legacyPath = launchAgentDirURL
+ .appendingPathComponent("\(legacyIdentifier).plist").path
+ if FileManager.default.fileExists(atPath: legacyPath) {
+ Logger.client.info("Unloading and removing legacy XPCService launch agent")
+ try? await launchctl("unload", legacyPath)
+ try? FileManager.default.removeItem(atPath: legacyPath)
}
}
}
diff --git a/Core/Sources/PersistMiddleware/Extensions/ChatMessage+Storage.swift b/Core/Sources/PersistMiddleware/Extensions/ChatMessage+Storage.swift
index 0fc5c849..8d73d1d3 100644
--- a/Core/Sources/PersistMiddleware/Extensions/ChatMessage+Storage.swift
+++ b/Core/Sources/PersistMiddleware/Extensions/ChatMessage+Storage.swift
@@ -15,11 +15,17 @@ extension ChatMessage {
var suggestedTitle: String?
var errorMessages: [String] = []
var steps: [ConversationProgressStep]
+ var thinking: [MessageThinking]
var editAgentRounds: [AgentRound]
+ var parentTurnId: String?
var panelMessages: [CopilotShowMessageParams]
var fileEdits: [FileEdit]
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 {
@@ -32,11 +38,24 @@ 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) ?? []
fileEdits = try container.decodeIfPresent([FileEdit].self, forKey: .fileEdits) ?? []
turnStatus = try container.decodeIfPresent(ChatMessage.TurnStatus.self, forKey: .turnStatus)
requestType = try container.decodeIfPresent(RequestType.self, forKey: .requestType) ?? .conversation
+ modelName = try container.decodeIfPresent(String.self, forKey: .modelName)
+ 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
@@ -49,11 +68,17 @@ extension ChatMessage {
suggestedTitle: String?,
errorMessages: [String] = [],
steps: [ConversationProgressStep]?,
+ thinking: [MessageThinking] = [],
editAgentRounds: [AgentRound]? = nil,
+ parentTurnId: String? = nil,
panelMessages: [CopilotShowMessageParams]? = nil,
fileEdits: [FileEdit]? = nil,
turnStatus: ChatMessage.TurnStatus? = nil,
- requestType: RequestType = .conversation
+ requestType: RequestType = .conversation,
+ modelName: String? = nil,
+ modelProviderName: String? = nil,
+ billingMultiplier: Float? = nil,
+ reasoningEffort: String? = nil
) {
self.content = content
self.contentImageReferences = contentImageReferences ?? []
@@ -63,11 +88,17 @@ extension ChatMessage {
self.suggestedTitle = suggestedTitle
self.errorMessages = errorMessages
self.steps = steps ?? []
+ self.thinking = thinking
self.editAgentRounds = editAgentRounds ?? []
+ self.parentTurnId = parentTurnId
self.panelMessages = panelMessages ?? []
self.fileEdits = fileEdits ?? []
self.turnStatus = turnStatus
self.requestType = requestType
+ self.modelName = modelName
+ self.modelProviderName = modelProviderName
+ self.billingMultiplier = billingMultiplier
+ self.reasoningEffort = reasoningEffort
}
}
@@ -81,11 +112,17 @@ extension ChatMessage {
suggestedTitle: self.suggestedTitle,
errorMessages: self.errorMessages,
steps: self.steps,
+ thinking: self.thinking,
editAgentRounds: self.editAgentRounds,
+ parentTurnId: self.parentTurnId,
panelMessages: self.panelMessages,
fileEdits: self.fileEdits,
turnStatus: self.turnStatus,
- requestType: self.requestType
+ requestType: self.requestType,
+ modelName: self.modelName,
+ modelProviderName: self.modelProviderName,
+ billingMultiplier: self.billingMultiplier,
+ reasoningEffort: self.reasoningEffort
)
// TODO: handle exception
@@ -118,10 +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 5489bf3c..6b8d0094 100644
--- a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift
+++ b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift
@@ -11,6 +11,7 @@ import SuggestionWidget
import PersistMiddleware
import ChatService
import Persist
+import Workspace
#if canImport(ChatTabPersistent)
import ChatTabPersistent
@@ -364,6 +365,9 @@ public final class GraphicalUserInterfaceController {
}
init() {
+ @Dependency(\.workspacePool) var workspacePool
+ @Dependency(\.workspaceInvoker) var workspaceInvoker
+
let chatTabPool = ChatTabPool()
let suggestionDependency = SuggestionWidgetControllerDependency()
let setupDependency: (inout DependencyValues) -> Void = { dependencies in
@@ -425,6 +429,12 @@ public final class GraphicalUserInterfaceController {
await commandHandler.handleCustomCommand(command)
}
}
+
+ workspaceInvoker.invokeFilespaceUpdate = { fileURL, content in
+ guard let (workspace, _) = try? await workspacePool.fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL)
+ else { return }
+ await workspace.didUpdateFilespace(fileURL: fileURL, content: content)
+ }
}
func start() {
diff --git a/Core/Sources/Service/GUI/WidgetDataSource.swift b/Core/Sources/Service/GUI/WidgetDataSource.swift
index 01611d11..2d0ecffc 100644
--- a/Core/Sources/Service/GUI/WidgetDataSource.swift
+++ b/Core/Sources/Service/GUI/WidgetDataSource.swift
@@ -9,6 +9,8 @@ import ChatAPIService
import PromptToCodeService
import SuggestionBasic
import SuggestionWidget
+import WorkspaceSuggestionService
+import Workspace
@MainActor
final class WidgetDataSource {}
@@ -47,7 +49,7 @@ extension WidgetDataSource: SuggestionWidgetDataSource {
onAcceptSuggestionTapped: {
Task {
let handler = PseudoCommandHandler()
- await handler.acceptSuggestion()
+ await handler.acceptSuggestion(.codeCompletion)
NSWorkspace.activatePreviousActiveXcode()
}
},
@@ -63,5 +65,45 @@ extension WidgetDataSource: SuggestionWidgetDataSource {
}
return nil
}
+
+ func nesSuggestionForFile(at url: URL) async -> NESCodeSuggestionProvider? {
+ for workspace in await Service.shared.workspacePool.workspaces.values {
+ if let filespace = workspace.filespaces[url],
+ let nesSuggestion = filespace.presentingNESSuggestion
+ {
+ let sourceSnapshot = await getSourceSnapshot(from: filespace)
+ return .init(
+ fileURL: url,
+ code: nesSuggestion.text,
+ sourceSnapshot: sourceSnapshot,
+ range: nesSuggestion.range,
+ language: filespace.language.rawValue,
+ onRejectSuggestionTapped: {
+ Task {
+ let handler = PseudoCommandHandler()
+ await handler.rejectNESSuggestions()
+ }
+ },
+ onAcceptNESSuggestionTapped: {
+ Task {
+ let handler = PseudoCommandHandler()
+ await handler.acceptSuggestion(.nes)
+ NSWorkspace.activatePreviousActiveXcode()
+ }
+ },
+ onDismissNESSuggestionTapped: {
+ // Refer to Code Completion suggestion, the `dismiss` action is not support
+ }
+ )
+ }
+ }
+
+ return nil
+ }
}
+
+@WorkspaceActor
+private func getSourceSnapshot(from filespace: Filespace) -> FilespaceSuggestionSnapshot {
+ return filespace.nesSuggestionSourceSnapshot
+}
diff --git a/Core/Sources/Service/Helpers.swift b/Core/Sources/Service/Helpers.swift
index 90ac6344..99dc2a65 100644
--- a/Core/Sources/Service/Helpers.swift
+++ b/Core/Sources/Service/Helpers.swift
@@ -6,6 +6,7 @@ extension NSError {
static func from(_ error: Error) -> NSError {
if let error = error as? ServerError {
var message = "Unknown"
+ var errorData: Codable? = nil
switch error {
case let .handlerUnavailable(handler):
message = "Handler unavailable: \(handler)."
@@ -29,8 +30,9 @@ extension NSError {
message = "Unable to send request: \(error.localizedDescription)."
case let .unableToSendNotification(error):
message = "Unable to send notification: \(error.localizedDescription)."
- case let .serverError(code, m, _):
+ case let .serverError(code, m, data):
message = "Server error: (\(code)) \(m)."
+ errorData = data
case let .invalidRequest(error):
message = "Invalid request: \(error?.localizedDescription ?? "Unknown")."
case .timeout:
@@ -38,9 +40,28 @@ extension NSError {
case .unknownError:
message = "Unknown error: \(error.localizedDescription)."
}
- return NSError(domain: "com.github.CopilotForXcode", code: -1, userInfo: [
- NSLocalizedDescriptionKey: message,
- ])
+
+ var userInfo: [String: Any] = [NSLocalizedDescriptionKey: message]
+
+ // Try to encode errorData to JSON for XPC transfer
+ if let errorData = errorData {
+ // Try to decode as MCPRegistryErrorData first
+ if let jsonData = try? JSONEncoder().encode(errorData),
+ let mcpErrorData = try? JSONDecoder().decode(MCPRegistryErrorData.self, from: jsonData) {
+ userInfo["errorType"] = mcpErrorData.errorType
+ if let status = mcpErrorData.status {
+ userInfo["status"] = status
+ }
+ if let shouldRetry = mcpErrorData.shouldRetry {
+ userInfo["shouldRetry"] = shouldRetry
+ }
+ } else if let jsonData = try? JSONEncoder().encode(errorData) {
+ // Fallback to encoding any Codable type
+ userInfo["serverErrorData"] = jsonData
+ }
+ }
+
+ return NSError(domain: "com.github.CopilotForXcode", code: -1, userInfo: userInfo)
}
if let error = error as? CancellationError {
return NSError(domain: "com.github.CopilotForXcode", code: -100, userInfo: [
diff --git a/Core/Sources/Service/RealtimeSuggestionController.swift b/Core/Sources/Service/RealtimeSuggestionController.swift
index 899865f1..d583d792 100644
--- a/Core/Sources/Service/RealtimeSuggestionController.swift
+++ b/Core/Sources/Service/RealtimeSuggestionController.swift
@@ -69,20 +69,13 @@ public actor RealtimeSuggestionController {
let handler = { [weak self] in
guard let self else { return }
await cancelInFlightTasks()
- await self.triggerPrefetchDebounced()
await self.notifyEditingFileChange(editor: sourceEditor.element)
+ await self.triggerPrefetchDebounced()
}
-
- if #available(macOS 13.0, *) {
- for await _ in valueChange._throttle(for: .milliseconds(200)) {
- if Task.isCancelled { return }
- await handler()
- }
- } else {
- for await _ in valueChange {
- if Task.isCancelled { return }
- await handler()
- }
+
+ for await _ in valueChange {
+ if Task.isCancelled { return }
+ await handler()
}
}
group.addTask {
@@ -95,16 +88,9 @@ public actor RealtimeSuggestionController {
)
}
- if #available(macOS 13.0, *) {
- for await _ in selectedTextChanged._throttle(for: .milliseconds(200)) {
- if Task.isCancelled { return }
- await handler()
- }
- } else {
- for await _ in selectedTextChanged {
- if Task.isCancelled { return }
- await handler()
- }
+ for await _ in selectedTextChanged._throttle(for: .milliseconds(200)) {
+ if Task.isCancelled { return }
+ await handler()
}
}
@@ -155,9 +141,6 @@ public actor RealtimeSuggestionController {
let authStatus = await Status.shared.getAuthStatus()
guard authStatus.status == .loggedIn else { return }
- guard UserDefaults.shared.value(for: \.realtimeSuggestionToggle)
- else { return }
-
if UserDefaults.shared.value(for: \.disableSuggestionFeatureGlobally),
let fileURL = await XcodeInspector.shared.safe.activeDocumentURL,
let (workspace, _) = try? await Service.shared.workspacePool
diff --git a/Core/Sources/Service/Service.swift b/Core/Sources/Service/Service.swift
index 8072778a..ab6c35e2 100644
--- a/Core/Sources/Service/Service.swift
+++ b/Core/Sources/Service/Service.swift
@@ -62,7 +62,10 @@ public final class Service {
keyBindingManager = .init(
workspacePool: workspacePool,
acceptSuggestion: {
- Task { await PseudoCommandHandler().acceptSuggestion() }
+ Task { await PseudoCommandHandler().acceptSuggestion(.codeCompletion) }
+ },
+ acceptNESSuggestion: {
+ Task { await PseudoCommandHandler().acceptSuggestion(.nes) }
},
expandSuggestion: {
if !ExpandableSuggestionService.shared.isSuggestionExpanded {
@@ -76,6 +79,15 @@ public final class Service {
},
dismissSuggestion: {
Task { await PseudoCommandHandler().dismissSuggestion() }
+ },
+ rejectNESSuggestion: {
+ Task { await PseudoCommandHandler().rejectNESSuggestions() }
+ },
+ goToNextEditSuggestion: {
+ Task { await PseudoCommandHandler().goToNextEditSuggestion() }
+ },
+ isNESPanelOutOfFrame: { [weak guiController] in
+ guiController?.store.state.suggestionWidgetState.panelState.nesSuggestionPanelState.isPanelOutOfFrame ?? false
}
)
let scheduledCleaner = ScheduledCleaner()
diff --git a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift
index 2ad3e765..2bdb8b91 100644
--- a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift
+++ b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift
@@ -10,6 +10,7 @@ import WorkspaceSuggestionService
import XcodeInspector
import XPCShared
import AXHelper
+import GitHubCopilotService
/// It's used to run some commands without really triggering the menu bar item.
///
@@ -57,17 +58,92 @@ struct PseudoCommandHandler {
.fetchOrCreateWorkspaceAndFilespace(fileURL: filespace.fileURL) else { return }
if Task.isCancelled { return }
+
+ let codeCompletionEnabled = UserDefaults.shared.value(for: \.realtimeSuggestionToggle)
+ // Enabled both by Feature Flag and User.
+ let nesEnabled = FeatureFlagNotifierImpl.shared.featureFlags.editorPreviewFeatures && UserDefaults.shared.value(for: \.realtimeNESToggle)
+ guard codeCompletionEnabled || nesEnabled else {
+ cleanupAllSuggestions(filespace: filespace, presenter: nil)
+ return
+ }
// Can't use handler if content is not available.
guard let editor = await getEditorContent(sourceEditor: sourceEditor)
else { return }
- let fileURL = filespace.fileURL
let presenter = PresentInWindowSuggestionPresenter()
presenter.markAsProcessing(true)
defer { presenter.markAsProcessing(false) }
+ do {
+ if codeCompletionEnabled {
+ try await _generateRealtimeCodeCompletionSuggestions(
+ editor: editor,
+ sourceEditor: sourceEditor,
+ filespace: filespace,
+ workspace: workspace,
+ presenter: presenter
+ )
+ } else {
+ cleanupCodeCompletionSuggestion(filespace: filespace, presenter: presenter)
+ }
+
+ if nesEnabled,
+ (codeCompletionEnabled == false || filespace.presentingSuggestion == nil) {
+ try await _generateRealtimeNESSuggestions(
+ editor: editor,
+ sourceEditor: sourceEditor,
+ filespace: filespace,
+ workspace: workspace,
+ presenter: presenter
+ )
+ } else {
+ cleanupNESSuggestion(filespace: filespace, presenter: presenter)
+ }
+
+ } catch {
+ cleanupAllSuggestions(filespace: filespace, presenter: presenter)
+ }
+ }
+
+ @WorkspaceActor
+ private func cleanupCodeCompletionSuggestion(
+ filespace: Filespace,
+ presenter: PresentInWindowSuggestionPresenter?
+ ) {
+ filespace.reset()
+ presenter?.discardSuggestion(fileURL: filespace.fileURL)
+ }
+
+ @WorkspaceActor
+ private func cleanupNESSuggestion(
+ filespace: Filespace,
+ presenter: PresentInWindowSuggestionPresenter?
+ ) {
+ filespace.resetNESSuggestion()
+ presenter?.discardNESSuggestion(fileURL: filespace.fileURL)
+ }
+
+ @WorkspaceActor
+ private func cleanupAllSuggestions(
+ filespace: Filespace,
+ presenter: PresentInWindowSuggestionPresenter?
+ ) {
+ cleanupCodeCompletionSuggestion(filespace: filespace, presenter: presenter)
+ cleanupNESSuggestion(filespace: filespace, presenter: presenter)
+ filespace.resetSnapshot()
+ filespace.resetNESSnapshot()
+ }
+
+ @WorkspaceActor
+ func _generateRealtimeCodeCompletionSuggestions(
+ editor: EditorContent,
+ sourceEditor: SourceEditor?,
+ filespace: Filespace,
+ workspace: Workspace,
+ presenter: PresentInWindowSuggestionPresenter
+ ) async throws {
if filespace.presentingSuggestion != nil {
// Check if the current suggestion is still valid.
if filespace.validateSuggestions(
@@ -76,37 +152,78 @@ struct PseudoCommandHandler {
) {
return
} else {
+ filespace.reset()
presenter.discardSuggestion(fileURL: filespace.fileURL)
}
}
-
- do {
- try await workspace.generateSuggestions(
- forFileAt: fileURL,
- editor: editor
+
+ let fileURL = filespace.fileURL
+
+ try await workspace.generateSuggestions(
+ forFileAt: fileURL,
+ editor: editor
+ )
+ let editorContent = sourceEditor?.getContent()
+ if let editorContent {
+ _ = filespace.validateSuggestions(
+ lines: editorContent.lines,
+ cursorPosition: editorContent.cursorPosition
)
- if let sourceEditor {
- let editorContent = sourceEditor.getContent()
- _ = filespace.validateSuggestions(
- lines: editorContent.lines,
- cursorPosition: editorContent.cursorPosition
+ }
+
+ if !filespace.errorMessage.isEmpty {
+ presenter
+ .presentWarningMessage(
+ filespace.errorMessage,
+ url: "https://github.com/github-copilot/signup/copilot_individual"
)
- }
- if !filespace.errorMessage.isEmpty {
- presenter
- .presentWarningMessage(
- filespace.errorMessage,
- url: "https://github.com/github-copilot/signup/copilot_individual"
- )
- }
- if filespace.presentingSuggestion != nil {
- presenter.presentSuggestion(fileURL: fileURL)
- workspace.notifySuggestionShown(fileFileAt: fileURL)
+ }
+ if filespace.presentingSuggestion != nil {
+ presenter.presentSuggestion(fileURL: fileURL)
+ workspace.notifySuggestionShown(fileFileAt: fileURL)
+ } else {
+ presenter.discardSuggestion(fileURL: fileURL)
+ }
+ }
+
+ @WorkspaceActor
+ func _generateRealtimeNESSuggestions(
+ editor: EditorContent,
+ sourceEditor: SourceEditor?,
+ filespace: Filespace,
+ workspace: Workspace,
+ presenter: PresentInWindowSuggestionPresenter
+ ) async throws {
+ if filespace.presentingNESSuggestion != nil {
+ // Check if the current NES suggestion is still valid.
+ if filespace.validateNESSuggestions(
+ lines: editor.lines,
+ cursorPosition: editor.cursorPosition
+ ) {
+ return
} else {
- presenter.discardSuggestion(fileURL: fileURL)
+ filespace.resetNESSuggestion()
+ presenter.discardNESSuggestion(fileURL: filespace.fileURL)
}
- } catch {
- return
+ }
+
+ let fileURL = filespace.fileURL
+
+ try await workspace.generateNESSuggestions(forFileAt: fileURL, editor: editor)
+
+ let editorContent = sourceEditor?.getContent()
+ if let editorContent {
+ _ = filespace.validateNESSuggestions(
+ lines: editorContent.lines,
+ cursorPosition: editorContent.cursorPosition
+ )
+ }
+ // TODO: handle errorMessage if any
+ if filespace.presentingNESSuggestion != nil {
+ presenter.presentNESSuggestion(fileURL: fileURL)
+ workspace.notifyNESSuggestionShown(forFileAt: fileURL)
+ } else {
+ presenter.discardNESSuggestion(fileURL: fileURL)
}
}
@@ -127,6 +244,24 @@ struct PseudoCommandHandler {
PresentInWindowSuggestionPresenter().discardSuggestion(fileURL: fileURL)
}
}
+
+ @WorkspaceActor
+ func invalidateRealtimeNESSuggestionsIfNeeded(fileURL: URL, sourceEditor: SourceEditor) async {
+ guard let (_, filespace) = try? await Service.shared.workspacePool
+ .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) else { return }
+
+ if filespace.presentingNESSuggestion == nil {
+ return // skip if there's no NES suggestion presented.
+ }
+
+ let content = sourceEditor.getContent()
+ if !filespace.validateNESSuggestions(
+ lines: content.lines,
+ cursorPosition: content.cursorPosition
+ ) {
+ PresentInWindowSuggestionPresenter().discardNESSuggestion(fileURL: fileURL)
+ }
+ }
func rejectSuggestions() async {
let handler = WindowBaseCommandHandler()
@@ -142,6 +277,21 @@ struct PseudoCommandHandler {
usesTabsForIndentation: false
))
}
+
+ func rejectNESSuggestions() async {
+ let handler = WindowBaseCommandHandler()
+ _ = try? await handler.rejectNESSuggestion(editor: .init(
+ content: "",
+ lines: [],
+ uti: "",
+ cursorPosition: .outOfScope,
+ cursorOffset: -1,
+ selections: [],
+ tabSize: 0,
+ indentSize: 0,
+ usesTabsForIndentation: false
+ ))
+ }
func handleCustomCommand(_ command: CustomCommand) async {
guard let editor = await {
@@ -248,14 +398,20 @@ struct PseudoCommandHandler {
}
}
- func acceptSuggestion() async {
+ func acceptSuggestion(_ suggestionType: CodeSuggestionType) async {
do {
if UserDefaults.shared.value(for: \.alwaysAcceptSuggestionWithAccessibilityAPI) {
throw CancellationError()
}
do {
- try await XcodeInspector.shared.safe.latestActiveXcode?
- .triggerCopilotCommand(name: "Accept Suggestion")
+ switch suggestionType {
+ case .codeCompletion:
+ try await XcodeInspector.shared.safe.latestActiveXcode?
+ .triggerCopilotCommand(name: "Accept Suggestion")
+ case .nes:
+ try await XcodeInspector.shared.safe.latestActiveXcode?
+ .triggerCopilotCommand(name: "Accept Next Edit Suggestion")
+ }
} catch {
let lastBundleNotFoundTime = Self.lastBundleNotFoundTime
let lastBundleDisabledTime = Self.lastBundleDisabledTime
@@ -318,7 +474,7 @@ struct PseudoCommandHandler {
}
let handler = WindowBaseCommandHandler()
do {
- guard let result = try await handler.acceptSuggestion(editor: .init(
+ let editor: EditorContent = .init(
content: content,
lines: lines,
uti: "",
@@ -328,7 +484,18 @@ struct PseudoCommandHandler {
tabSize: 0,
indentSize: 0,
usesTabsForIndentation: false
- )) else { return }
+ )
+
+ let result = try await {
+ switch suggestionType {
+ case .codeCompletion:
+ return try await handler.acceptSuggestion(editor: editor)
+ case .nes:
+ return try await handler.acceptNESSuggestion(editor: editor)
+ }
+ }()
+
+ guard let result else { return }
try injectUpdatedCodeWithAccessibilityAPI(result, focusElement: focusElement)
} catch {
@@ -336,6 +503,27 @@ struct PseudoCommandHandler {
}
}
}
+
+ func goToNextEditSuggestion() async {
+ do {
+ guard let sourceEditor = await XcodeInspector.shared.safe.focusedEditor,
+ let fileURL = sourceEditor.realtimeDocumentURL
+ else { return }
+ let (workspace, _) = try await Service.shared.workspacePool
+ .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL)
+
+ guard let suggestion = await workspace.getNESSuggestion(forFileAt: fileURL)
+ else { return }
+
+ AXHelper.scrollSourceEditorToLine(
+ suggestion.range.start.line,
+ content: sourceEditor.getContent().content,
+ focusedElement: sourceEditor.element
+ )
+ } catch {
+ // Handle if needed
+ }
+ }
func dismissSuggestion() async {
guard let documentURL = await XcodeInspector.shared.safe.activeDocumentURL else { return }
diff --git a/Core/Sources/Service/SuggestionCommandHandler/SuggestionCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/SuggestionCommandHandler.swift
index 3d612e82..7aa5d20a 100644
--- a/Core/Sources/Service/SuggestionCommandHandler/SuggestionCommandHandler.swift
+++ b/Core/Sources/Service/SuggestionCommandHandler/SuggestionCommandHandler.swift
@@ -11,8 +11,12 @@ protocol SuggestionCommandHandler {
@ServiceActor
func rejectSuggestion(editor: EditorContent) async throws -> UpdatedContent?
@ServiceActor
+ func rejectNESSuggestion(editor: EditorContent) async throws -> UpdatedContent?
+ @ServiceActor
func acceptSuggestion(editor: EditorContent) async throws -> UpdatedContent?
@ServiceActor
+ func acceptNESSuggestion(editor: EditorContent) async throws -> UpdatedContent?
+ @ServiceActor
func acceptPromptToCode(editor: EditorContent) async throws -> UpdatedContent?
@ServiceActor
func presentRealtimeSuggestions(editor: EditorContent) async throws -> UpdatedContent?
diff --git a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift
index 694bff25..4e0b2a74 100644
--- a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift
+++ b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift
@@ -57,8 +57,21 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler {
if filespace.presentingSuggestion != nil {
presenter.presentSuggestion(fileURL: fileURL)
workspace.notifySuggestionShown(fileFileAt: fileURL)
+ presenter.discardNESSuggestion(fileURL: fileURL)
} else {
presenter.discardSuggestion(fileURL: fileURL)
+ try Task.checkCancellation()
+
+ // When no code completion generated, fallback to NES
+ try await workspace.generateNESSuggestions(forFileAt: fileURL, editor: editor)
+
+ try Task.checkCancellation()
+
+ if filespace.presentingNESSuggestion != nil {
+ presenter.presentNESSuggestion(fileURL: fileURL)
+ } else {
+ presenter.discardNESSuggestion(fileURL: fileURL)
+ }
}
}
@@ -137,6 +150,28 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler {
workspace.rejectSuggestion(forFileAt: fileURL, editor: editor)
presenter.discardSuggestion(fileURL: fileURL)
}
+
+ func rejectNESSuggestion(editor: EditorContent) async throws -> UpdatedContent? {
+ Task {
+ do {
+ try await _rejectNESSuggestion(editor: editor)
+ } catch {
+ presenter.presentError(error)
+ }
+ }
+ return nil
+ }
+
+ @WorkspaceActor
+ private func _rejectNESSuggestion(editor: EditorContent) async throws {
+ guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL
+ else { return }
+
+ let (workspace, _) = try await Service.shared.workspacePool
+ .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL)
+ workspace.rejectNESSuggestion(forFileAt: fileURL, editor: editor)
+ presenter.discardNESSuggestion(fileURL: fileURL)
+ }
@WorkspaceActor
func acceptSuggestion(editor: EditorContent) async throws -> UpdatedContent? {
@@ -174,6 +209,41 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler {
return nil
}
+
+ @WorkspaceActor
+ func acceptNESSuggestion(editor: EditorContent) async throws -> UpdatedContent? {
+ guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL
+ else { return nil }
+ let (workspace, _) = try await Service.shared.workspacePool
+ .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL)
+
+ let injector = SuggestionInjector()
+ var lines = editor.lines
+ var cursorPosition = editor.cursorPosition
+ var extraInfo = SuggestionInjector.ExtraInfo()
+
+ if let acceptedSuggestion = workspace.acceptNESSuggestion(
+ forFileAt: fileURL, editor: editor
+ ) {
+ injector.acceptSuggestion(
+ intoContentWithoutSuggestion: &lines,
+ cursorPosition: &cursorPosition,
+ completion: acceptedSuggestion,
+ extraInfo: &extraInfo,
+ isNES: true
+ )
+
+ presenter.discardNESSuggestion(fileURL: fileURL)
+
+ return .init(
+ content: String(lines.joined(separator: "")),
+ newSelection: .cursor(cursorPosition),
+ modifications: extraInfo.modifications
+ )
+ }
+
+ return nil
+ }
func acceptPromptToCode(editor: EditorContent) async throws -> UpdatedContent? {
guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL
diff --git a/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift b/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift
index 4007a06c..80f60141 100644
--- a/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift
+++ b/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift
@@ -11,6 +11,13 @@ struct PresentInWindowSuggestionPresenter {
controller.suggestCode()
}
}
+
+ func presentNESSuggestion(fileURL: URL) {
+ Task { @MainActor in
+ let controller = Service.shared.guiController.widgetController
+ controller.suggestNESCode()
+ }
+ }
func expandSuggestion(fileURL: URL) {
Task { @MainActor in
@@ -25,6 +32,13 @@ struct PresentInWindowSuggestionPresenter {
controller.discardSuggestion()
}
}
+
+ func discardNESSuggestion(fileURL: URL) {
+ Task { @MainActor in
+ let controller = Service.shared.guiController.widgetController
+ controller.discardNESSuggestion()
+ }
+ }
func markAsProcessing(_ isProcessing: Bool) {
Task { @MainActor in
diff --git a/Core/Sources/Service/XPCService.swift b/Core/Sources/Service/XPCService.swift
index 72769162..b64e841c 100644
--- a/Core/Sources/Service/XPCService.swift
+++ b/Core/Sources/Service/XPCService.swift
@@ -9,6 +9,7 @@ import XPCShared
import HostAppActivator
import XcodeInspector
import GitHubCopilotViewModel
+import Workspace
import ConversationServiceProvider
public class XPCService: NSObject, XPCServiceProtocol {
@@ -120,6 +121,15 @@ public class XPCService: NSObject, XPCServiceProtocol {
try await handler.rejectSuggestion(editor: editor)
}
}
+
+ public func getNESSuggestionRejectedCode(
+ editorContent: Data,
+ withReply reply: @escaping (Data?, Error?) -> Void
+ ) {
+ replyWithUpdatedContent(editorContent: editorContent, withReply: reply) { handler, editor in
+ try await handler.rejectNESSuggestion(editor: editor)
+ }
+ }
public func getSuggestionAcceptedCode(
editorContent: Data,
@@ -129,6 +139,15 @@ public class XPCService: NSObject, XPCServiceProtocol {
try await handler.acceptSuggestion(editor: editor)
}
}
+
+ public func getNESSuggestionAcceptedCode(
+ editorContent: Data,
+ withReply reply: @escaping (Data?, Error?) -> Void
+ ) {
+ replyWithUpdatedContent(editorContent: editorContent, withReply: reply) { handler, editor in
+ try await handler.acceptNESSuggestion(editor: editor)
+ }
+ }
public func getPromptToCodeAcceptedCode(
editorContent: Data,
@@ -229,6 +248,29 @@ public class XPCService: NSObject, XPCServiceProtocol {
reply(nil)
}
}
+
+ public func toggleRealtimeNES(withReply reply: @escaping (Error?) -> Void) {
+ guard AXIsProcessTrusted() else {
+ reply(NoAccessToAccessibilityAPIError())
+ return
+ }
+ Task { @ServiceActor in
+ await Service.shared.realtimeSuggestionController.cancelInFlightTasks()
+ let on = !UserDefaults.shared.value(for: \.realtimeNESToggle)
+ UserDefaults.shared.set(on, for: \.realtimeNESToggle)
+ Task { @MainActor in
+ Service.shared.guiController.store
+ .send(.suggestionWidget(.toastPanel(.toast(.toast(
+ "Next Edit Suggestions (NES) is turned \(on ? "on" : "off")",
+ .info,
+ nil
+ )))))
+ Service.shared.guiController.store
+ .send(.suggestionWidget(.panel(.onRealtimeNESToggleChanged(on))))
+ }
+ reply(nil)
+ }
+ }
public func postNotification(name: String, withReply reply: @escaping () -> Void) {
reply()
@@ -290,22 +332,61 @@ public class XPCService: NSObject, XPCServiceProtocol {
}
}
- public func updateMCPServerToolsStatus(tools: Data) {
+ public func updateMCPServerToolsStatus(
+ tools: Data,
+ chatAgentMode: Data?,
+ customChatModeId: Data?,
+ workspaceFolders: Data?
+ ) {
// Decode the data
let decoder = JSONDecoder()
var collections: [UpdateMCPToolsStatusServerCollection] = []
+ var folders: [WorkspaceFolder]? = nil
+ var mode: ChatMode? = nil
+ var modeId: String? = nil
do {
collections = try decoder.decode([UpdateMCPToolsStatusServerCollection].self, from: tools)
+ if let workspaceFolders = workspaceFolders {
+ folders = try? decoder.decode([WorkspaceFolder].self, from: workspaceFolders)
+ }
+ if let chatAgentMode = chatAgentMode {
+ mode = try? decoder.decode(ChatMode.self, from: chatAgentMode)
+ }
+ if let customChatModeId = customChatModeId {
+ modeId = try? decoder.decode(String.self, from: customChatModeId)
+ }
if collections.isEmpty {
return
}
} catch {
- Logger.service.error("Failed to decode MCP server collections: \(error)")
+ Logger.service.error("Failed to decode MCP server collections or workspace folders: \(error)")
return
}
Task { @MainActor in
- await GitHubCopilotService.updateAllClsMCP(collections: collections)
+ // Only use auth service when ALL three parameters are provided.
+ if mode != nil, modeId != nil, folders != nil {
+ do {
+ if let uri = folders!.first?.uri, let projectRootURL = URL(string: uri) {
+ if let service = GitHubCopilotService.getProjectGithubCopilotService(
+ for: projectRootURL
+ ) {
+ let params = UpdateMCPToolsStatusParams(
+ chatModeKind: mode,
+ customChatModeId: modeId,
+ workspaceFolders: folders,
+ servers: collections
+ )
+ try await service.updateMCPToolsStatus(params: params)
+ }
+ }
+ } catch {
+ Logger.service.error("Failed to update MCP tool status via auth service: \(error)")
+ }
+ } else {
+ // Fallback to legacy/global update when context not fully provided.
+ await GitHubCopilotService.updateAllClsMCP(collections: collections)
+ }
}
}
@@ -329,7 +410,7 @@ public class XPCService: NSObject, XPCServiceProtocol {
reply(data, nil)
} catch {
Logger.service.error("Failed to list MCP Registry servers: \(error)")
- reply(nil, error)
+ reply(nil, NSError.from(error))
}
}
}
@@ -352,7 +433,21 @@ public class XPCService: NSObject, XPCServiceProtocol {
reply(data, nil)
} catch {
Logger.service.error("Failed to get MCP Registry servers: \(error)")
- reply(nil, error)
+ reply(nil, NSError.from(error))
+ }
+ }
+ }
+
+ public func getMCPRegistryAllowlist(withReply reply: @escaping (Data?, Error?) -> Void) {
+ Task { @MainActor in
+ do {
+ let service = try GitHubCopilotViewModel.shared.getGitHubCopilotAuthService()
+ let response = try await service.getMCPRegistryAllowlist()
+ let data = try? JSONEncoder().encode(response)
+ reply(data, nil)
+ } catch {
+ Logger.service.error("Failed to get MCP Registry allowlist: \(error)")
+ reply(nil, NSError.from(error))
}
}
}
@@ -369,26 +464,81 @@ public class XPCService: NSObject, XPCServiceProtocol {
}
}
- public func updateToolsStatus(tools: Data, withReply reply: @escaping (Data?) -> Void) {
+ public func refreshClientTools(withReply reply: @escaping (Data?) -> Void) {
+ Task { @MainActor in
+ await GitHubCopilotService.refreshClientTools()
+ let availableLanguageModelTools = CopilotLanguageModelToolManager.getAvailableLanguageModelTools()
+ if let availableLanguageModelTools = availableLanguageModelTools {
+ let data = try? JSONEncoder().encode(availableLanguageModelTools)
+ reply(data)
+ } else {
+ reply(nil)
+ }
+ }
+ }
+
+ public func updateToolsStatus(
+ tools: Data,
+ chatAgentMode: Data?,
+ customChatModeId: Data?,
+ workspaceFolders: Data?,
+ withReply reply: @escaping (Data?) -> Void
+ ) {
// Decode the data
let decoder = JSONDecoder()
var toolStatusUpdates: [ToolStatusUpdate] = []
+ var folders: [WorkspaceFolder]? = nil
+ var mode: ChatMode? = nil
+ var modeId: String? = nil
do {
toolStatusUpdates = try decoder.decode([ToolStatusUpdate].self, from: tools)
+ if let workspaceFolders = workspaceFolders {
+ folders = try? decoder.decode([WorkspaceFolder].self, from: workspaceFolders)
+ }
+ if let chatAgentMode = chatAgentMode {
+ mode = try? decoder.decode(ChatMode.self, from: chatAgentMode)
+ }
+ if let customChatModeId = customChatModeId {
+ modeId = try? decoder.decode(String.self, from: customChatModeId)
+ }
if toolStatusUpdates.isEmpty {
let emptyData = try JSONEncoder().encode([LanguageModelTool]())
reply(emptyData)
return
}
} catch {
- Logger.service.error("Failed to decode built-in tools: \(error)")
+ Logger.service.error("Failed to decode built-in tools or workspace folders: \(error)")
reply(nil)
return
}
Task { @MainActor in
- let updatedTools = await GitHubCopilotService.updateAllCLSTools(tools: toolStatusUpdates)
-
+ var updatedTools: [LanguageModelTool] = []
+ if mode != nil, modeId != nil, folders != nil {
+ // Use auth service path when all three context parameters are present.
+ do {
+ if let uri = folders!.first?.uri, let projectRootURL = URL(string: uri) {
+ if let service = GitHubCopilotService.getProjectGithubCopilotService(
+ for: projectRootURL
+ ) {
+ updatedTools = try await service.updateToolsStatus(
+ params: .init(
+ chatmodeKind: mode,
+ customChatModeId: modeId,
+ workspaceFolders: folders,
+ tools: toolStatusUpdates
+ )
+ )
+ }
+ }
+ } catch {
+ Logger.service.error("Failed contextual tools update: \(error)")
+ updatedTools = await GitHubCopilotService.updateAllCLSTools(tools: toolStatusUpdates)
+ }
+ } else {
+ // Fallback without contextual parameters.
+ updatedTools = await GitHubCopilotService.updateAllCLSTools(tools: toolStatusUpdates)
+ }
// Encode and return the updated tools
do {
let data = try JSONEncoder().encode(updatedTools)
@@ -409,6 +559,33 @@ public class XPCService: NSObject, XPCServiceProtocol {
reply(data)
}
+ public func getCopilotPolicy(
+ withReply reply: @escaping (Data?) -> Void
+ ) {
+ let copilotPolicy = CopilotPolicyNotifierImpl.shared.copilotPolicy
+ let data = try? JSONEncoder().encode(copilotPolicy)
+ reply(data)
+ }
+
+ public func getModes(workspaceFolders: Data?, withReply reply: @escaping (Data?, Error?) -> Void) {
+ Task { @MainActor in
+ do {
+ let service = try GitHubCopilotViewModel.shared.getGitHubCopilotAuthService()
+ var folders: [WorkspaceFolder]? = nil
+ if let workspaceFolders = workspaceFolders {
+ folders = try JSONDecoder().decode([WorkspaceFolder].self, from: workspaceFolders)
+ }
+
+ let modes = try await service.modes(workspaceFolders: folders)
+ let data = try JSONEncoder().encode(modes)
+ reply(data, nil)
+ } catch {
+ Logger.service.error("Failed to get modes: \(error.localizedDescription)")
+ reply(nil, NSError.from(error))
+ }
+ }
+ }
+
// MARK: - Auth
public func signOutAllGitHubCopilotService() {
Task { @MainActor in
@@ -430,6 +607,21 @@ public class XPCService: NSObject, XPCServiceProtocol {
}
}
+ public func updateCopilotModels(withReply reply: @escaping (Data?, Error?) -> Void) {
+ Task { @MainActor in
+ do {
+ let service = try GitHubCopilotViewModel.shared.getGitHubCopilotAuthService()
+ let models = try await service.models()
+ CopilotModelManager.updateLLMs(models)
+ let data = try JSONEncoder().encode(models)
+ reply(data, nil)
+ } catch {
+ Logger.service.error("Failed to get models: \(error.localizedDescription)")
+ reply(nil, NSError.from(error))
+ }
+ }
+ }
+
// MARK: - BYOK
public func saveBYOKApiKey(_ params: Data, withReply reply: @escaping (Data?) -> Void) {
let decoder = JSONDecoder()
diff --git a/Core/Sources/SuggestionInjector/SuggestionInjector.swift b/Core/Sources/SuggestionInjector/SuggestionInjector.swift
index df78acf5..c2edff6c 100644
--- a/Core/Sources/SuggestionInjector/SuggestionInjector.swift
+++ b/Core/Sources/SuggestionInjector/SuggestionInjector.swift
@@ -20,7 +20,8 @@ public struct SuggestionInjector {
cursorPosition: inout CursorPosition,
completion: CodeSuggestion,
extraInfo: inout ExtraInfo,
- suggestionLineLimit: Int? = nil
+ suggestionLineLimit: Int? = nil,
+ isNES: Bool = false
) {
extraInfo.didChangeContent = true
extraInfo.didChangeCursorPosition = true
@@ -77,6 +78,35 @@ public struct SuggestionInjector {
at: toBeInserted[0].startIndex
)
}
+
+ // appending suffix text not in range if needed.
+ if isNES,
+ let lastRemovedLine,
+ !lastRemovedLine.isEmptyOrNewLine,
+ end.character >= 0,
+ end.character < lastRemovedLine.count,
+ !toBeInserted.isEmpty
+ {
+ let suffixStartIndex = lastRemovedLine.utf16.index(
+ lastRemovedLine.utf16.startIndex,
+ offsetBy: end.character,
+ limitedBy: lastRemovedLine.utf16.endIndex
+ ) ?? lastRemovedLine.utf16.endIndex
+ var suffix = String(lastRemovedLine[suffixStartIndex...])
+ if suffix.last?.isNewline ?? false {
+ suffix.removeLast(1)
+ }
+ let lastIndex = toBeInserted.endIndex - 1
+ var lastLine = toBeInserted[lastIndex]
+ if lastLine.last?.isNewline ?? false {
+ lastLine.removeLast(1)
+ lastLine.append(contentsOf: suffix)
+ lastLine.append(lineEnding)
+ } else {
+ lastLine.append(contentsOf: suffix)
+ }
+ toBeInserted[lastIndex] = lastLine
+ }
let recoveredSuffixLength = recoverSuffixIfNeeded(
endOfReplacedContent: end,
diff --git a/Core/Sources/SuggestionService/SuggestionService.swift b/Core/Sources/SuggestionService/SuggestionService.swift
index 2802d787..1766001c 100644
--- a/Core/Sources/SuggestionService/SuggestionService.swift
+++ b/Core/Sources/SuggestionService/SuggestionService.swift
@@ -64,6 +64,28 @@ public extension SuggestionService {
return try await getSuggestion(request, workspaceInfo)
}
+
+ func getNESSuggestions(
+ _ request: SuggestionRequest,
+ workspaceInfo: CopilotForXcodeKit.WorkspaceInfo,
+ ) async throws -> [SuggestionBasic.CodeSuggestion] {
+ var getNESSuggestion = suggestionProvider.getNESSuggestions(_:workspaceInfo:)
+ let configuration = await configuration
+
+ for middleware in middlewares.reversed() {
+ getNESSuggestion = { [getNESSuggestion] request, workspaceInfo in
+ try await middleware.getNESSuggestion(
+ request,
+ configuration: configuration,
+ next: { [getNESSuggestion] request in
+ try await getNESSuggestion(request, workspaceInfo)
+ }
+ )
+ }
+ }
+
+ return try await getNESSuggestion(request, workspaceInfo)
+ }
func notifyAccepted(
_ suggestion: SuggestionBasic.CodeSuggestion,
diff --git a/Core/Sources/SuggestionWidget/AgentConfigurationWidgetView.swift b/Core/Sources/SuggestionWidget/AgentConfigurationWidgetView.swift
new file mode 100644
index 00000000..c6275778
--- /dev/null
+++ b/Core/Sources/SuggestionWidget/AgentConfigurationWidgetView.swift
@@ -0,0 +1,1190 @@
+import AppKit
+import ChatService
+import ComposableArchitecture
+import ConversationServiceProvider
+import ConversationTab
+import GitHubCopilotService
+import LanguageServerProtocol
+import Logger
+import SharedUIComponents
+import SuggestionBasic
+import SwiftUI
+import XcodeInspector
+
+struct SelectedAgentModel: Equatable {
+ let displayName: String
+ let modelName: String
+ let source: ModelSource
+
+ enum ModelSource: Equatable {
+ case copilot
+ case byok(provider: String)
+ }
+}
+
+struct AgentConfigurationWidgetView: View {
+ let store: StoreOf
+
+ @State private var showPopover = false
+ @State private var isHovered = false
+ @State private var selectedToolStates: [String: [String: Bool]] = [:]
+ @State private var selectedModel: SelectedAgentModel? = nil
+ @State private var searchText = ""
+ @State private var isSearchFieldExpanded = false
+ @State private var generateHandoffExample: Bool = true
+ @Environment(\.colorScheme) var colorScheme
+
+ var body: some View {
+ WithPerceptionTracking {
+ if store.isPanelDisplayed {
+ VStack {
+ buildAgentConfigurationButton()
+ .popover(isPresented: $showPopover) {
+ buildConfigView(currentMode: store.currentMode).padding(.horizontal, 4)
+ }
+ }
+ .animation(.easeInOut(duration: 0.2), value: store.isPanelDisplayed)
+ .onChange(of: showPopover) { newValue in
+ if newValue {
+ // Load state from agent file when popover is opened
+ loadToolStatesFromAgentFile(currentMode: store.currentMode)
+ // Refresh client tools to get any late-arriving server tools
+ Task {
+ await GitHubCopilotService.refreshClientTools()
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func buildAgentConfigurationButton() -> some View {
+ let fontSize = store.lineHeight * 0.7
+ let lineHeight = store.lineHeight
+
+ ZStack {
+ Button(action: { showPopover.toggle() }) {
+ HStack(spacing: 4) {
+ Image(systemName: "square.and.pencil")
+ .resizable()
+ .scaledToFit()
+ .frame(width: fontSize, height: fontSize)
+ Text("Customize Agent")
+ .font(.system(size: fontSize))
+ .fixedSize()
+ }
+ .frame(height: lineHeight)
+ .foregroundColor(isHovered ? Color("ItemSelectedColor") : .secondary)
+ }
+ .buttonStyle(.plain)
+ .contentShape(Capsule())
+ .help("Configure tools and model for custom agent")
+ .onHover { isHovered = $0 }
+ }
+ }
+
+ @ViewBuilder
+ private func buildConfigView(currentMode: ConversationMode?) -> some View {
+ if let currentMode = currentMode {
+ VStack(spacing: 0) {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Configure Model")
+ .font(.system(size: 15, weight: .bold))
+
+ Text("The AI model to use when running the prompt. If not specified, the currently selected model in model picker is used.")
+ .font(.system(size: 11))
+ .foregroundColor(.secondary)
+ .padding(.bottom, 8)
+
+ AgentModelPickerSection(
+ selectedModel: $selectedModel
+ )
+
+ Divider()
+
+ if currentMode.handOffs?.isEmpty ?? true {
+ Text("Configure Handoffs")
+ .font(.system(size: 15, weight: .bold))
+
+ Text("Suggested next actions or prompts to transition between custom agents. Handoff buttons appear as interactive suggestions after a chat response completes.")
+ .font(.system(size: 11))
+ .foregroundColor(.secondary)
+
+ Toggle(isOn: $generateHandoffExample) {
+ Text("Generate Handoff Example")
+ .font(.system(size: 11, weight: .regular))
+ }
+ .toggleStyle(.checkbox)
+ .help("Adds a starter handoff example to the agent file YAML frontmatter.")
+
+ Divider()
+ }
+
+ // Title with Search
+ HStack {
+ Text("Configure Tools")
+ .font(.system(size: 15, weight: .bold))
+
+ Spacer()
+
+ CollapsibleSearchField(
+ searchText: $searchText,
+ isExpanded: $isSearchFieldExpanded,
+ placeholderString: "Search tools..."
+ )
+ }
+
+ Text("A list of built-in tools and MCP tools that are available for this agent. If a given tool is not available when running the agent, it is ignored.")
+ .font(.system(size: 11))
+ .foregroundColor(.secondary)
+ .padding(.bottom, 8)
+
+ // MCP Tools Section
+ AgentToolsSection(
+ title: "MCP Tools",
+ currentMode: currentMode,
+ selectedToolStates: $selectedToolStates,
+ searchText: searchText
+ )
+
+ // Built-In Tools Section
+ AgentBuiltInToolsSection(
+ title: "Built-In Tools",
+ currentMode: currentMode,
+ selectedToolStates: $selectedToolStates,
+ searchText: searchText
+ )
+ }
+ .padding(12)
+ }
+ .frame(width: 500, height: 600)
+
+ Divider()
+
+ // Buttons
+ HStack(spacing: 12) {
+ Button(action: { showPopover = false }) {
+ Text("Cancel")
+ .font(.system(size: 13, weight: .medium))
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.bordered)
+
+ Button(action: {
+ updateAgentTools(selectedToolStates: selectedToolStates, currentMode: currentMode)
+ applyAgentFileChanges(
+ selectedModel: selectedModel,
+ generateHandoffExample: generateHandoffExample,
+ currentMode: currentMode
+ )
+ showPopover = false
+ }) {
+ Text("Apply")
+ .font(.system(size: 13, weight: .medium))
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.bordered)
+ .keyboardShortcut(.defaultAction)
+ }
+ .padding(12)
+ }
+ .transition(.opacity.combined(with: .scale(scale: 0.95)))
+ } else {
+ // Should never be shown since widget only displays when mode exists
+ VStack {
+ Text("No agent mode available")
+ .foregroundColor(.secondary)
+ }
+ .frame(width: 500, height: 600)
+ }
+ }
+
+ // MARK: - Helper functions
+
+ // MARK: - Agent File Utilities
+
+ private struct AgentFileAccess {
+ let documentURL: URL
+ let content: String
+ }
+
+ private func validateAndReadAgentFile() -> AgentFileAccess? {
+ guard let documentURL = store.withState({ $0.focusedEditor?.realtimeDocumentURL }) else {
+ Logger.extension.error("Could not access agent file - documentURL is nil")
+ return nil
+ }
+ guard documentURL.pathExtension == "md" else {
+ Logger.extension.error("Could not access agent file - invalid extension")
+ return nil
+ }
+ guard documentURL.lastPathComponent.hasSuffix(".agent.md") else {
+ Logger.extension.error("Could not access agent file - filename does not end with .agent.md")
+ return nil
+ }
+ guard let content = try? String(contentsOf: documentURL) else {
+ Logger.extension.error("Could not access agent file - unable to read file")
+ return nil
+ }
+ return AgentFileAccess(documentURL: documentURL, content: content)
+ }
+
+ private struct YAMLFrontmatterInfo {
+ var lines: [String]
+ let frontmatterEndIndex: Int?
+ let modelLineIndex: Int?
+ let toolsLineIndex: Int?
+ let handoffsLineIndex: Int?
+ }
+
+ private func parseYAMLFrontmatter(content: String) -> YAMLFrontmatterInfo {
+ let lines = content.components(separatedBy: .newlines)
+ var inFrontmatter = false
+ var frontmatterEndIndex: Int?
+ var modelLineIndex: Int?
+ var toolsLineIndex: Int?
+ var handoffsLineIndex: Int?
+
+ for (idx, line) in lines.enumerated() {
+ let trimmed = line.trimmingCharacters(in: .whitespaces)
+ if trimmed == "---" {
+ if !inFrontmatter {
+ inFrontmatter = true
+ } else {
+ inFrontmatter = false
+ frontmatterEndIndex = idx
+ break
+ }
+ } else if inFrontmatter {
+ if trimmed.hasPrefix("model:") {
+ modelLineIndex = idx
+ } else if trimmed.hasPrefix("tools:") {
+ toolsLineIndex = idx
+ } else if trimmed.hasPrefix("handoffs:") || trimmed.hasPrefix("handOffs:") {
+ handoffsLineIndex = idx
+ }
+ }
+ }
+
+ return YAMLFrontmatterInfo(
+ lines: lines,
+ frontmatterEndIndex: frontmatterEndIndex,
+ modelLineIndex: modelLineIndex,
+ toolsLineIndex: toolsLineIndex,
+ handoffsLineIndex: handoffsLineIndex
+ )
+ }
+
+ private func writeToAgentFile(url: URL, content: String, successMessage: String) {
+ do {
+ try content.write(to: url, atomically: true, encoding: .utf8)
+ Logger.extension.info(successMessage)
+ } catch {
+ Logger.extension.error("Error writing agent file: \(error)")
+ }
+ }
+
+ private func formatModelLine(_ selectedModel: SelectedAgentModel?) -> String? {
+ guard let model = selectedModel else { return nil }
+ let sourceLabel: String
+ switch model.source {
+ case .copilot:
+ sourceLabel = "copilot"
+ case let .byok(provider):
+ sourceLabel = provider
+ }
+ return "model: '\(model.displayName) (\(sourceLabel))'"
+ }
+
+ private func loadMCPToolStates(enabledTools: Set) {
+ guard let mcpServerTools = CopilotMCPToolManager.getAvailableMCPServerToolsCollections() else { return }
+ for server in mcpServerTools {
+ for tool in server.tools {
+ let configurationKey = AgentModeToolHelpers.makeConfigurationKey(
+ serverName: server.name,
+ toolName: tool.name
+ )
+ selectedToolStates["mcp"]?[configurationKey] = enabledTools.contains(configurationKey)
+ }
+ }
+ }
+
+ private func loadBuiltInToolStates(enabledTools: Set) {
+ guard let builtInTools = CopilotLanguageModelToolManager.getAvailableLanguageModelTools() else { return }
+ for tool in builtInTools {
+ selectedToolStates["builtin"]?[tool.name] = enabledTools.contains(tool.name)
+ }
+ }
+
+ private func collectMCPToolUpdates(selectedToolStates: [String: [String: Bool]]) -> [UpdateMCPToolsStatusServerCollection] {
+ guard let mcpStates = selectedToolStates["mcp"],
+ let mcpServerTools = CopilotMCPToolManager.getAvailableMCPServerToolsCollections() else {
+ return []
+ }
+
+ return mcpServerTools.map { server in
+ let toolUpdates = server.tools.map { tool in
+ let configurationKey = AgentModeToolHelpers.makeConfigurationKey(
+ serverName: server.name,
+ toolName: tool.name
+ )
+ let isEnabled = mcpStates[configurationKey] ?? false
+ return UpdatedMCPToolsStatus(
+ name: tool.name,
+ status: isEnabled ? .enabled : .disabled
+ )
+ }
+ return UpdateMCPToolsStatusServerCollection(
+ name: server.name,
+ tools: toolUpdates
+ )
+ }
+ }
+
+ private func collectBuiltInToolUpdates(selectedToolStates: [String: [String: Bool]]) -> [ToolStatusUpdate] {
+ guard let builtInStates = selectedToolStates["builtin"],
+ let builtInTools = CopilotLanguageModelToolManager.getAvailableLanguageModelTools() else {
+ return []
+ }
+
+ return builtInTools.map { tool in
+ let isEnabled = builtInStates[tool.name] ?? false
+ return ToolStatusUpdate(
+ name: tool.name,
+ status: isEnabled ? .enabled : .disabled
+ )
+ }
+ }
+
+ private func updateMCPToolsViaAPI(
+ service: GitHubCopilotService,
+ mcpCollections: [UpdateMCPToolsStatusServerCollection],
+ chatModeKind: ChatMode?,
+ customChatModeId: String?,
+ workspaceFolders: [WorkspaceFolder]
+ ) async {
+ guard !mcpCollections.isEmpty else { return }
+ do {
+ let _ = try await service.updateMCPToolsStatus(
+ params: UpdateMCPToolsStatusParams(
+ chatModeKind: chatModeKind,
+ customChatModeId: customChatModeId,
+ workspaceFolders: workspaceFolders,
+ servers: mcpCollections
+ )
+ )
+ Logger.extension.info("MCP tools updated via API")
+
+ // Notify Settings app about custom agent tool changes
+ DistributedNotificationCenter.default().postNotificationName(
+ .gitHubCopilotCustomAgentToolsDidChange,
+ object: nil,
+ userInfo: nil,
+ deliverImmediately: true
+ )
+ } catch {
+ Logger.extension.error("Error updating MCP tools via API: \(error)")
+ }
+ }
+
+ private func updateBuiltInToolsViaAPI(
+ service: GitHubCopilotService,
+ builtInToolUpdates: [ToolStatusUpdate],
+ chatModeKind: ChatMode?,
+ customChatModeId: String?,
+ workspaceFolders: [WorkspaceFolder]
+ ) async {
+ guard !builtInToolUpdates.isEmpty else { return }
+ do {
+ let _ = try await service.updateToolsStatus(
+ params: UpdateToolsStatusParams(
+ chatmodeKind: chatModeKind,
+ customChatModeId: customChatModeId,
+ workspaceFolders: workspaceFolders,
+ tools: builtInToolUpdates
+ )
+ )
+ Logger.extension.info("Built-in tools updated via API")
+
+ // Notify Settings app about custom agent tool changes
+ DistributedNotificationCenter.default().postNotificationName(
+ .gitHubCopilotCustomAgentToolsDidChange,
+ object: nil,
+ userInfo: nil,
+ deliverImmediately: true
+ )
+ } catch {
+ Logger.extension.error("Error updating built-in tools via API: \(error)")
+ }
+ }
+
+ private func parseModelFromMode(_ mode: ConversationMode?) -> SelectedAgentModel? {
+ guard let mode = mode,
+ let modelString = mode.model else {
+ return nil
+ }
+
+ // Parse format: "displayName (copilot)" or "displayName (providerName)"
+ if let openParen = modelString.lastIndex(of: "("),
+ let closeParen = modelString.lastIndex(of: ")") {
+ let displayName = String(modelString[.. Int? {
+ let modelLine = formatModelLine(selectedModel)
+
+ if let modelLine = modelLine {
+ if let modelIdx = yamlInfo.modelLineIndex {
+ yamlInfo.lines[modelIdx] = modelLine
+ return modelIdx
+ } else if let endIdx = yamlInfo.frontmatterEndIndex {
+ yamlInfo.lines.insert(modelLine, at: endIdx)
+ return endIdx
+ }
+ } else if let modelIdx = yamlInfo.modelLineIndex {
+ yamlInfo.lines.remove(at: modelIdx)
+ return nil
+ }
+ return yamlInfo.modelLineIndex
+ }
+
+ private func applyHandoffsUpdate(to yamlInfo: inout YAMLFrontmatterInfo, afterModelIndex modelIndex: Int?) {
+ guard yamlInfo.handoffsLineIndex == nil else { return }
+
+ let snippet = [
+ "handoffs:",
+ " - label: Start Implementation",
+ " agent: implementation",
+ " prompt: Now implement the plan outlined above.",
+ " send: true",
+ ]
+
+ if let mIdx = modelIndex {
+ yamlInfo.lines.insert(contentsOf: snippet, at: mIdx + 1)
+ } else if let endIdx = yamlInfo.frontmatterEndIndex {
+ yamlInfo.lines.insert(contentsOf: snippet, at: endIdx)
+ }
+ }
+
+ // MARK: - MCP Tools Section
+
+ private struct AgentToolsSection: View {
+ let title: String
+ let currentMode: ConversationMode
+ @Binding var selectedToolStates: [String: [String: Bool]]
+ let searchText: String
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(title)
+ .font(.system(size: 14, weight: .semibold))
+
+ let mcpServerTools = CopilotMCPToolManager.getAvailableMCPServerToolsCollections() ?? []
+
+ if mcpServerTools.isEmpty {
+ Text("No MCP tools available.")
+ .foregroundColor(.secondary)
+ .font(.system(size: 13))
+ .padding(.vertical, 8)
+ } else {
+ ForEach(mcpServerTools, id: \.name) { server in
+ AgentMCPServerSection(
+ serverTools: server,
+ currentMode: currentMode,
+ selectedToolStates: $selectedToolStates,
+ searchText: searchText
+ )
+ }
+ }
+ }
+ }
+ }
+
+ // MARK: - MCP Server Section
+
+ private struct AgentMCPServerSection: View {
+ let serverTools: MCPServerToolsCollection
+ let currentMode: ConversationMode
+ @Binding var selectedToolStates: [String: [String: Bool]]
+ let searchText: String
+
+ @State private var isExpanded: Bool = false
+ @State private var checkboxState: CheckboxMixedState = .off
+
+ private func matchesSearch(_ text: String, _ description: String?) -> Bool {
+ guard !searchText.isEmpty else { return true }
+ let lowercasedSearch = searchText.lowercased()
+ return text.lowercased().contains(lowercasedSearch) ||
+ (description?.lowercased().contains(lowercasedSearch) ?? false)
+ }
+
+ private var serverNameMatches: Bool {
+ matchesSearch(serverTools.name, nil)
+ }
+
+ private var hasMatchingTools: Bool {
+ guard !searchText.isEmpty else { return false }
+ if serverNameMatches { return true }
+ return serverTools.tools.contains { tool in
+ matchesSearch(tool.name, tool.description)
+ }
+ }
+
+ private var filteredTools: [MCPTool] {
+ guard !searchText.isEmpty else { return serverTools.tools }
+ if serverNameMatches { return serverTools.tools }
+ return serverTools.tools.filter { tool in
+ matchesSearch(tool.name, tool.description)
+ }
+ }
+
+ var body: some View {
+ // Don't show this server if search is active and there are no matches
+ if searchText.isEmpty || hasMatchingTools {
+ VStack(alignment: .leading, spacing: 0) {
+ DisclosureGroup(isExpanded: $isExpanded) {
+ VStack(alignment: .leading, spacing: 0) {
+ Divider()
+ .padding(.vertical, 4)
+
+ ForEach(filteredTools, id: \.name) { tool in
+ let configurationKey = AgentModeToolHelpers.makeConfigurationKey(
+ serverName: serverTools.name,
+ toolName: tool.name
+ )
+ let isSelected = selectedToolStates["mcp"]?[configurationKey] ?? AgentModeToolHelpers.isToolEnabledInMode(
+ configurationKey: configurationKey,
+ currentStatus: .enabled,
+ selectedMode: currentMode
+ )
+ AgentToolRow(
+ toolName: tool.name,
+ toolDescription: tool.description,
+ isSelected: isSelected,
+ isBlocked: serverTools.status == .blocked || serverTools.status == .error,
+ onToggle: { isSelected in
+ if selectedToolStates["mcp"] == nil {
+ selectedToolStates["mcp"] = [:]
+ }
+ selectedToolStates["mcp"]?[configurationKey] = isSelected
+ updateServerSelectionState()
+ }
+ )
+ .padding(.leading, 20)
+ }
+ }
+ } label: {
+ HStack(spacing: 8) {
+ MixedStateCheckbox(
+ title: "",
+ font: .systemFont(ofSize: 13),
+ state: $checkboxState,
+ action: {
+ // Toggle based on current state
+ switch checkboxState {
+ case .off, .mixed:
+ toggleAllTools(selected: true)
+ case .on:
+ toggleAllTools(selected: false)
+ }
+ }
+ )
+ .disabled(serverTools.status == .blocked || serverTools.status == .error)
+
+ HStack(spacing: 8) {
+ if serverTools.status == .blocked || serverTools.status == .error {
+ Text("MCP Server: \(serverTools.name)")
+ .font(.system(size: 13, weight: .medium))
+ } else {
+ let selectedCount = serverTools.tools.filter { tool in
+ let configurationKey = AgentModeToolHelpers.makeConfigurationKey(
+ serverName: serverTools.name,
+ toolName: tool.name
+ )
+ if let state = selectedToolStates["mcp"]?[configurationKey] {
+ return state
+ }
+ return AgentModeToolHelpers.isToolEnabledInMode(
+ configurationKey: configurationKey,
+ currentStatus: .enabled,
+ selectedMode: currentMode
+ )
+ }.count
+ Text("MCP Server: \(serverTools.name) ")
+ .font(.system(size: 13, weight: .medium))
+ + Text("(\(selectedCount) of \(serverTools.tools.count) Selected)")
+ .font(.system(size: 13, weight: .regular))
+ }
+
+ if serverTools.status == .error {
+ Image(systemName: "xmark.circle.fill")
+ .foregroundColor(.red)
+ .font(.system(size: 11))
+ } else if serverTools.status == .blocked {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .foregroundColor(.orange)
+ .font(.system(size: 11))
+ }
+ }
+ .contentShape(Rectangle())
+ .onTapGesture {
+ withAnimation {
+ isExpanded.toggle()
+ }
+ }
+
+ Spacer()
+ }
+ }
+ .padding(.vertical, 4)
+ }
+ .disabled(serverTools.status != .running)
+ .onAppear {
+ updateServerSelectionState()
+ }
+ .onChange(of: selectedToolStates) { _ in
+ updateServerSelectionState()
+ }
+ .onChange(of: searchText) { _ in
+ if hasMatchingTools && !isExpanded && serverTools.status == .running {
+ isExpanded = true
+ }
+ }
+ }
+ }
+
+ private func toggleAllTools(selected: Bool) {
+ if selectedToolStates["mcp"] == nil {
+ selectedToolStates["mcp"] = [:]
+ }
+ for tool in serverTools.tools {
+ let configurationKey = AgentModeToolHelpers.makeConfigurationKey(
+ serverName: serverTools.name,
+ toolName: tool.name
+ )
+ selectedToolStates["mcp"]?[configurationKey] = selected
+ }
+ updateServerSelectionState()
+ }
+
+ private func isToolSelected(_ tool: MCPTool) -> Bool {
+ let configurationKey = AgentModeToolHelpers.makeConfigurationKey(
+ serverName: serverTools.name,
+ toolName: tool.name
+ )
+ if let state = selectedToolStates["mcp"]?[configurationKey] {
+ return state
+ }
+ return AgentModeToolHelpers.isToolEnabledInMode(
+ configurationKey: configurationKey,
+ currentStatus: .enabled,
+ selectedMode: currentMode
+ )
+ }
+
+ private func updateServerSelectionState() {
+ guard serverTools.status != .blocked && serverTools.status != .error && !serverTools.tools.isEmpty else {
+ checkboxState = .off
+ return
+ }
+
+ let selectedCount = serverTools.tools.filter { isToolSelected($0) }.count
+ checkboxState = selectedCount == 0 ? .off : (selectedCount == serverTools.tools.count ? .on : .mixed)
+ }
+ }
+
+ // MARK: - Built-In Tools Section
+
+ private struct AgentBuiltInToolsSection: View {
+ let title: String
+ let currentMode: ConversationMode
+ @Binding var selectedToolStates: [String: [String: Bool]]
+ let searchText: String
+
+ @State private var isExpanded: Bool = false
+ @State private var checkboxState: CheckboxMixedState = .off
+
+ private func matchesBuiltInSearch(_ tool: LanguageModelTool) -> Bool {
+ guard !searchText.isEmpty else { return true }
+ let lowercasedSearch = searchText.lowercased()
+ return tool.name.lowercased().contains(lowercasedSearch) ||
+ (tool.displayName?.lowercased().contains(lowercasedSearch) ?? false) ||
+ (tool.description?.lowercased().contains(lowercasedSearch) ?? false)
+ }
+
+ private var builtInNameMatches: Bool {
+ guard !searchText.isEmpty else { return false }
+ let lowercasedSearch = searchText.lowercased()
+ return "built-in".contains(lowercasedSearch) || "builtin".contains(lowercasedSearch)
+ }
+
+ private func hasMatchingTools(builtInTools: [LanguageModelTool]) -> Bool {
+ guard !searchText.isEmpty else { return false }
+ if builtInNameMatches { return true }
+ return builtInTools.contains { matchesBuiltInSearch($0) }
+ }
+
+ private func filteredTools(builtInTools: [LanguageModelTool]) -> [LanguageModelTool] {
+ guard !searchText.isEmpty else { return builtInTools }
+ if builtInNameMatches { return builtInTools }
+ return builtInTools.filter { matchesBuiltInSearch($0) }
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(title)
+ .font(.system(size: 14, weight: .semibold))
+
+ let builtInTools = CopilotLanguageModelToolManager.getAvailableLanguageModelTools() ?? []
+
+ if builtInTools.isEmpty {
+ Text("No built-in tools available.")
+ .foregroundColor(.secondary)
+ .font(.system(size: 13))
+ .padding(.vertical, 8)
+ } else if searchText.isEmpty || hasMatchingTools(builtInTools: builtInTools) {
+ VStack(alignment: .leading, spacing: 0) {
+ DisclosureGroup(isExpanded: $isExpanded) {
+ VStack(alignment: .leading, spacing: 0) {
+ Divider()
+ .padding(.vertical, 4)
+
+ ForEach(filteredTools(builtInTools: builtInTools), id: \.name) { tool in
+ let isSelected = selectedToolStates["builtin"]?[tool.name] ?? AgentModeToolHelpers.isToolEnabledInMode(
+ configurationKey: tool.name,
+ currentStatus: tool.status,
+ selectedMode: currentMode
+ )
+ AgentToolRow(
+ toolName: tool.displayName ?? tool.name,
+ toolDescription: tool.description,
+ isSelected: isSelected,
+ isBlocked: false,
+ onToggle: { isSelected in
+ if selectedToolStates["builtin"] == nil {
+ selectedToolStates["builtin"] = [:]
+ }
+ selectedToolStates["builtin"]?[tool.name] = isSelected
+ updateBuiltInSelectionState(builtInTools: builtInTools)
+ }
+ )
+ .padding(.leading, 20)
+ }
+ }
+ } label: {
+ HStack(spacing: 8) {
+ MixedStateCheckbox(
+ title: "",
+ font: .systemFont(ofSize: 13),
+ state: $checkboxState,
+ action: {
+ // Toggle based on current state
+ switch checkboxState {
+ case .off, .mixed:
+ toggleAllBuiltInTools(selected: true, builtInTools: builtInTools)
+ case .on:
+ toggleAllBuiltInTools(selected: false, builtInTools: builtInTools)
+ }
+ }
+ )
+
+ let selectedCount = builtInTools.filter { tool in
+ if let state = selectedToolStates["builtin"]?[tool.name] {
+ return state
+ }
+ return AgentModeToolHelpers.isToolEnabledInMode(
+ configurationKey: tool.name,
+ currentStatus: tool.status,
+ selectedMode: currentMode
+ )
+ }.count
+ (Text("Built-In ")
+ .font(.system(size: 13, weight: .medium))
+ + Text("(\(selectedCount) of \(builtInTools.count) Selected)")
+ .font(.system(size: 13, weight: .regular))
+ .foregroundColor(.secondary))
+ .contentShape(Rectangle())
+ .onTapGesture {
+ withAnimation {
+ isExpanded.toggle()
+ }
+ }
+
+ Spacer()
+ }
+ }
+ .padding(.vertical, 4)
+ }
+ .onAppear {
+ updateBuiltInSelectionState(builtInTools: builtInTools)
+ }
+ .onChange(of: selectedToolStates) { _ in
+ updateBuiltInSelectionState(builtInTools: builtInTools)
+ }
+ .onChange(of: searchText) { _ in
+ if hasMatchingTools(builtInTools: builtInTools) && !isExpanded {
+ isExpanded = true
+ }
+ }
+ }
+ }
+ }
+
+ private func toggleAllBuiltInTools(selected: Bool, builtInTools: [LanguageModelTool]) {
+ if selectedToolStates["builtin"] == nil {
+ selectedToolStates["builtin"] = [:]
+ }
+ for tool in builtInTools {
+ selectedToolStates["builtin"]?[tool.name] = selected
+ }
+ updateBuiltInSelectionState(builtInTools: builtInTools)
+ }
+
+ private func isBuiltInToolSelected(_ tool: LanguageModelTool) -> Bool {
+ if let state = selectedToolStates["builtin"]?[tool.name] {
+ return state
+ }
+ return AgentModeToolHelpers.isToolEnabledInMode(
+ configurationKey: tool.name,
+ currentStatus: tool.status,
+ selectedMode: currentMode
+ )
+ }
+
+ private func updateBuiltInSelectionState(builtInTools: [LanguageModelTool]) {
+ guard !builtInTools.isEmpty else {
+ checkboxState = .off
+ return
+ }
+
+ let selectedCount = builtInTools.filter { isBuiltInToolSelected($0) }.count
+ checkboxState = selectedCount == 0 ? .off : (selectedCount == builtInTools.count ? .on : .mixed)
+ }
+ }
+
+ // MARK: - Agent Tool Row
+
+ private struct AgentToolRow: View {
+ let toolName: String
+ let toolDescription: String?
+ let isSelected: Bool
+ let isBlocked: Bool
+ let onToggle: (Bool) -> Void
+
+ var body: some View {
+ HStack(alignment: .center) {
+ Toggle(isOn: Binding(
+ get: { isSelected },
+ set: { onToggle($0) }
+ )) {
+ VStack(alignment: .leading, spacing: 2) {
+ HStack(spacing: 8) {
+ Text(toolName)
+ .font(.system(size: 12, weight: .medium))
+
+ if let description = toolDescription {
+ Text(description)
+ .font(.system(size: 11))
+ .foregroundColor(.secondary)
+ .help(description)
+ .lineLimit(1)
+ }
+ }
+ }
+ }
+ .toggleStyle(.checkbox)
+ .disabled(isBlocked)
+ }
+ .padding(.vertical, 4)
+ }
+ }
+
+ // MARK: - Agent Model Picker Section
+
+ private struct AgentModelPickerSection: View {
+ @Binding var selectedModel: SelectedAgentModel?
+ @State private var copilotModels: [LLMModel] = []
+ @State private var byokModels: [LLMModel] = []
+ @State private var modelCache: [String: String] = [:]
+
+ // Target width for menu items (popover width minus padding and margins)
+ // Popover is 500pt wide, subtract horizontal padding (12pt * 2) and menu item padding (8pt * 2)
+ let targetMenuItemWidth: CGFloat = 460
+ let attributes: [NSAttributedString.Key: NSFont] = ModelMenuItemFormatter.attributes
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Menu {
+ // None option
+ Button(action: {
+ selectedModel = nil
+ }) {
+ Text(createModelMenuItemAttributedString(
+ modelName: "Not Specified",
+ isSelected: selectedModel == nil,
+ multiplierText: ""
+ ))
+ }
+
+ Divider()
+
+ if let model = copilotModels.first(where: { $0.isAutoModel }) {
+ Button(action: { selectModel(model) }) {
+ Text(createModelMenuItemAttributedString(
+ modelName: model.displayName ?? model.modelName,
+ isSelected: isModelSelected(model),
+ multiplierText: modelCache[model.modelName] ?? "Variable",
+ isDegraded: model.degradationReason != nil
+ ))
+ }
+
+ Divider()
+ }
+
+ // Copilot models section
+ if !copilotModels.isEmpty {
+ Section(header: Text("Copilot Models")) {
+ ForEach(copilotModels.filter { !$0.isAutoModel }, id: \.modelName) { model in
+ Button(action: { selectModel(model) }) {
+ Text(createModelMenuItemAttributedString(
+ modelName: model.displayName ?? model.modelName,
+ isSelected: isModelSelected(model),
+ multiplierText: modelCache[model.modelName] ?? "",
+ isDegraded: model.degradationReason != nil
+ ))
+ }
+ }
+ }
+ }
+
+ // BYOK models section
+ if !byokModels.isEmpty {
+ Divider()
+ Section(header: Text("BYOK Models")) {
+ ForEach(byokModels, id: \.modelName) { model in
+ Button(action: { selectModel(model) }) {
+ Text(createModelMenuItemAttributedString(
+ modelName: model.displayName ?? model.modelName,
+ isSelected: isModelSelected(model),
+ multiplierText: modelCache[model.modelName] ?? ""
+ ))
+ }
+ }
+ }
+ }
+ } label: {
+ HStack {
+ Text(selectedModelDisplayText())
+ .font(.system(size: 12))
+ .foregroundColor(selectedModel == nil ? .secondary : .primary)
+ Spacer()
+ Image(systemName: "chevron.up.chevron.down")
+ .font(.system(size: 10))
+ .foregroundColor(.secondary)
+ }
+ .padding(.horizontal, 8)
+ .padding(.vertical, 6)
+ .background(Color.primary.opacity(0.05))
+ .cornerRadius(6)
+ }
+ .buttonStyle(.plain)
+ .onAppear {
+ loadModels()
+ }
+ }
+ }
+
+ private func selectModel(_ model: LLMModel) {
+ selectedModel = SelectedAgentModel(
+ displayName: model.displayName ?? model.modelName,
+ modelName: model.modelName,
+ source: model.providerName == nil ? .copilot : .byok(provider: model.providerName!)
+ )
+ }
+
+ private func isModelSelected(_ model: LLMModel) -> Bool {
+ guard let selected = selectedModel else { return false }
+ if selected.modelName != model.modelName { return false }
+
+ switch selected.source {
+ case .copilot:
+ return model.providerName == nil
+ case let .byok(provider):
+ return model.providerName?.lowercased() == provider.lowercased()
+ }
+ }
+
+ private func loadModels() {
+ copilotModels = CopilotModelManager.getAvailableChatLLMs(scope: .agentPanel)
+ byokModels = BYOKModelManager.getAvailableChatLLMs(scope: .agentPanel)
+
+ var newCache: [String: String] = [:]
+ let allModels = copilotModels + byokModels
+ for model in allModels {
+ newCache[model.modelName] = ModelMenuItemFormatter.getMultiplierText(for: model)
+ }
+ modelCache = newCache
+ }
+
+ private func selectedModelDisplayText() -> String {
+ guard let model = selectedModel else {
+ return "Select a model..."
+ }
+
+ let sourceLabel: String
+ switch model.source {
+ case .copilot:
+ sourceLabel = "copilot"
+ case let .byok(provider):
+ sourceLabel = provider
+ }
+
+ return "\(model.displayName) (\(sourceLabel))"
+ }
+
+ private func createModelMenuItemAttributedString(
+ modelName: String,
+ isSelected: Bool,
+ multiplierText: String,
+ isDegraded: Bool = false
+ ) -> AttributedString {
+ return ModelMenuItemFormatter.createModelMenuItemAttributedString(
+ modelName: modelName,
+ isSelected: isSelected,
+ multiplierText: multiplierText,
+ targetWidth: targetMenuItemWidth,
+ isDegraded: isDegraded
+ )
+ }
+ }
+}
diff --git a/Core/Sources/SuggestionWidget/ChatWindow/ChatLoginView.swift b/Core/Sources/SuggestionWidget/ChatWindow/ChatLoginView.swift
index 4223e2b2..0a70e8ba 100644
--- a/Core/Sources/SuggestionWidget/ChatWindow/ChatLoginView.swift
+++ b/Core/Sources/SuggestionWidget/ChatWindow/ChatLoginView.swift
@@ -77,7 +77,7 @@ struct ChatLoginView: View {
Button("Cancel", role: .cancel, action: {})
.scaledFont(.body)
- Button("Copy Code and Open", action: viewModel.copyAndOpen)
+ Button("Copy Code and Open", action: { viewModel.copyAndOpen(fromHostApp: false) })
.scaledFont(.body)
} message: { response in
Text("""
diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift
index 665ccd70..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/CodeReviewPanelView.swift b/Core/Sources/SuggestionWidget/CodeReviewPanelView.swift
index 214996a8..f7359baa 100644
--- a/Core/Sources/SuggestionWidget/CodeReviewPanelView.swift
+++ b/Core/Sources/SuggestionWidget/CodeReviewPanelView.swift
@@ -233,9 +233,8 @@ private struct CommentDetailView: View {
}
var fileNameView: some View {
- HStack(spacing: 8) {
+ HStack(alignment: .center, spacing: 8) {
drawFileIcon(fileURL)
- .resizable()
.scaledToFit()
.frame(width: 16, height: 16)
diff --git a/Core/Sources/SuggestionWidget/Extensions/Helper.swift b/Core/Sources/SuggestionWidget/Extensions/Helper.swift
index 06b55832..b59276f7 100644
--- a/Core/Sources/SuggestionWidget/Extensions/Helper.swift
+++ b/Core/Sources/SuggestionWidget/Extensions/Helper.swift
@@ -3,8 +3,16 @@ import AppKit
struct LocationStrategyHelper {
/// `lineNumber` is 0-based
- static func getLineFrame(_ lineNumber: Int, in editor: AXUIElement, with lines: [String]) -> CGRect? {
- guard editor.isSourceEditor,
+ ///
+ /// - Parameters:
+ /// - length: If specified, use this length instead of the actual line length. Useful when you want to get the exact line height and y that ignores the unwrappded lines.
+ static func getLineFrame(
+ _ lineNumber: Int,
+ in editor: AXUIElement,
+ with lines: [String],
+ length: Int? = nil
+ ) -> CGRect? {
+ guard editor.isNonNavigatorSourceEditor,
lineNumber < lines.count && lineNumber >= 0
else {
return nil
@@ -16,7 +24,15 @@ struct LocationStrategyHelper {
characterPosition += lines[i].count + 1
}
- var range = CFRange(location: characterPosition, length: lines[lineNumber].count)
+ let rangeLength: Int = {
+ if let length {
+ return min(length, lines[lineNumber].count)
+ } else {
+ return lines[lineNumber].count
+ }
+ }()
+
+ var range = CFRange(location: characterPosition, length: rangeLength)
guard let rangeValue = AXValueCreate(AXValueType.cfRange, &range) else {
return nil
}
diff --git a/Core/Sources/SuggestionWidget/Extensions/WidgetWindowsController+AgentConfigurationWidget.swift b/Core/Sources/SuggestionWidget/Extensions/WidgetWindowsController+AgentConfigurationWidget.swift
new file mode 100644
index 00000000..d68f297c
--- /dev/null
+++ b/Core/Sources/SuggestionWidget/Extensions/WidgetWindowsController+AgentConfigurationWidget.swift
@@ -0,0 +1,40 @@
+import AppKit
+import XcodeInspector
+import Preferences
+import ChatService
+import ConversationServiceProvider
+
+extension WidgetWindowsController {
+ @MainActor
+ func hideAgentConfigurationWidgetWindow() {
+ windows.agentConfigurationWidgetWindow.alphaValue = 0
+ windows.agentConfigurationWidgetWindow.setIsVisible(false)
+ }
+
+ @MainActor
+ func displayAgentConfigurationWidgetWindow() {
+ windows.agentConfigurationWidgetWindow.setIsVisible(true)
+ windows.agentConfigurationWidgetWindow.alphaValue = 1
+ windows.agentConfigurationWidgetWindow.orderFrontRegardless()
+ }
+
+ @MainActor
+ func applyOpacityForAgentConfigurationWidget(by noFocus: Bool? = nil) {
+ let state = store.withState { $0.panelState.agentConfigurationWidgetState }
+ guard let noFocus = noFocus,
+ !noFocus,
+ state.focusedEditor != nil
+ else {
+ hideAgentConfigurationWidgetWindow()
+ return
+ }
+
+ let currentMode = state.currentMode
+
+ if currentMode != nil {
+ displayAgentConfigurationWidgetWindow()
+ } else {
+ hideAgentConfigurationWidgetWindow()
+ }
+ }
+}
diff --git a/Core/Sources/SuggestionWidget/Extensions/WidgetWindowsController+FeatureFlags.swift b/Core/Sources/SuggestionWidget/Extensions/WidgetWindowsController+FeatureFlags.swift
new file mode 100644
index 00000000..bb456805
--- /dev/null
+++ b/Core/Sources/SuggestionWidget/Extensions/WidgetWindowsController+FeatureFlags.swift
@@ -0,0 +1,27 @@
+import GitHubCopilotService
+
+extension WidgetWindowsController {
+
+ @MainActor
+ var isNESFeatureFlagEnabled: Bool {
+ FeatureFlagNotifierImpl.shared.featureFlags.editorPreviewFeatures
+ }
+
+ func setupFeatureFlagObservers() {
+ Task { @MainActor in
+ let sinker = FeatureFlagNotifierImpl.shared.featureFlagsDidChange
+ .sink(receiveValue: { [weak self] _ in
+ self?.onFeatureFlagChanged()
+ })
+
+ await self.storeCancellables([sinker])
+ }
+ }
+
+ @MainActor
+ func onFeatureFlagChanged() {
+ if !isNESFeatureFlagEnabled {
+ hideAllNESWindows()
+ }
+ }
+}
diff --git a/Core/Sources/SuggestionWidget/Extensions/WidgetWindowsController+NES.swift b/Core/Sources/SuggestionWidget/Extensions/WidgetWindowsController+NES.swift
new file mode 100644
index 00000000..984fe3c4
--- /dev/null
+++ b/Core/Sources/SuggestionWidget/Extensions/WidgetWindowsController+NES.swift
@@ -0,0 +1,122 @@
+import AppKit
+import GitHubCopilotService
+
+extension WidgetWindowsController {
+ func setupNESSuggestionPanelObservers() {
+ Task { @MainActor in
+ let nesContentPublisher = store.publisher
+ .map(\.panelState.nesSuggestionPanelState.nesContent)
+ .removeDuplicates()
+ .sink { [weak self] _ in
+ Task { [weak self] in
+ await self?.updateWindowLocation(animated: false, immediately: true)
+ }
+ }
+
+ await self.storeCancellables([nesContentPublisher])
+ }
+ }
+
+ @MainActor
+ func applyOpacityForNESWindows(by noFocus: Bool) {
+ guard !noFocus, isNESFeatureFlagEnabled
+ else {
+ hideAllNESWindows()
+ return
+ }
+
+ displayAllNESWindows()
+ }
+
+ @MainActor
+ func hideAllNESWindows() {
+ windows.nesMenuWindow.alphaValue = 0
+ windows.nesDiffWindow.setIsVisible(false)
+
+ hideNESDiffWindow()
+
+ windows.nesNotificationWindow.alphaValue = 0
+ windows.nesNotificationWindow.setIsVisible(false)
+ }
+
+ @MainActor
+ func displayAllNESWindows() {
+ windows.nesMenuWindow.alphaValue = 1
+ windows.nesDiffWindow.setIsVisible(true)
+
+ windows.nesDiffWindow.alphaValue = 1
+ windows.nesDiffWindow.setIsVisible(true)
+
+ windows.nesNotificationWindow.alphaValue = 1
+ windows.nesNotificationWindow.setIsVisible(true)
+ }
+
+ @MainActor
+ func hideNESDiffWindow() {
+ NSAnimationContext.runAnimationGroup { context in
+ context.duration = 0.2
+ windows.nesDiffWindow.animator().alphaValue = 0
+ windows.nesDiffWindow.setIsVisible(false)
+ }
+ }
+
+ @MainActor
+ func updateNESDiffWindowFrame(
+ _ location: WidgetLocation.NESPanelLocation,
+ animated: Bool,
+ trigger: WidgetLocation.LocationTrigger
+ ) async {
+ windows.nesDiffWindow.layoutIfNeeded()
+ guard let contentView = windows.nesDiffWindow.contentView
+ else {
+ return
+ }
+
+ let effectiveSize: NSSize? = {
+ let fittingSize = contentView.fittingSize
+ if fittingSize.width > 0 && fittingSize.height > 0 {
+ return fittingSize
+ }
+
+ let intrinsicSize = contentView.intrinsicContentSize
+ if intrinsicSize.width > 0 && intrinsicSize.height > 0 {
+ return intrinsicSize
+ }
+
+ return nil
+ }()
+
+ guard let contentSize = effectiveSize,
+ contentSize.width.isFinite,
+ contentSize.height.isFinite,
+ let frame = location.calcDiffViewFrame(contentSize: contentSize)
+ else {
+ return
+ }
+
+ windows.nesDiffWindow.setFrame(
+ frame,
+ display: false,
+ animate: animated
+ )
+ }
+
+ @MainActor
+ func updateNESNotificationWindowFrame(
+ _ location: WidgetLocation.NESPanelLocation,
+ animated: Bool
+ ) async {
+ var notificationWindowFrame = windows.nesNotificationWindow.frame
+ let scrollViewFrame = location.scrollViewFrame
+ let screenFrame = location.screenFrame
+
+ notificationWindowFrame.origin.x = scrollViewFrame.minX + scrollViewFrame.width / 2 - notificationWindowFrame.width / 2
+ notificationWindowFrame.origin.y = screenFrame.height - scrollViewFrame.maxY + Style.nesSuggestionMenuLeadingPadding * 2
+
+ windows.nesNotificationWindow.setFrame(
+ notificationWindowFrame,
+ display: false,
+ animate: animated
+ )
+ }
+}
diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/AgentConfigurationWidgetFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/AgentConfigurationWidgetFeature.swift
new file mode 100644
index 00000000..ef5ac74d
--- /dev/null
+++ b/Core/Sources/SuggestionWidget/FeatureReducers/AgentConfigurationWidgetFeature.swift
@@ -0,0 +1,65 @@
+import ComposableArchitecture
+import Foundation
+import SuggestionBasic
+import XcodeInspector
+import ChatTab
+import ConversationTab
+import ChatService
+import ConversationServiceProvider
+
+@Reducer
+public struct AgentConfigurationWidgetFeature {
+ @ObservableState
+ public struct State: Equatable {
+ public var focusedEditor: SourceEditor? = nil
+ public var isPanelDisplayed: Bool = false
+ public var currentMode: ConversationMode? = nil
+
+ public var lineHeight: Double = 16.0
+ }
+
+ public enum Action: Equatable {
+ case setCurrentMode(ConversationMode?)
+ case onFocusedEditorChanged(SourceEditor?)
+ }
+
+ public init() {}
+
+ public var body: some ReducerOf {
+ Reduce { state, action in
+ switch action {
+ case .onFocusedEditorChanged(let editor):
+ state.focusedEditor = editor
+ return .run { send in
+ let currentMode = await getCurrentMode(for: editor)
+ await send(.setCurrentMode(currentMode))
+ }
+ case .setCurrentMode(let mode):
+ state.currentMode = mode
+ return .none
+ }
+ }
+ }
+}
+
+private func getCurrentMode(for focusedEditor: SourceEditor?) async -> ConversationMode? {
+ guard let documentURL = focusedEditor?.realtimeDocumentURL,
+ documentURL.pathExtension == "md",
+ documentURL.lastPathComponent.hasSuffix(".agent.md") else {
+ return nil
+ }
+
+ // Load all conversation modes
+ guard let modes = await SharedChatService.shared.loadConversationModes() else {
+ return nil
+ }
+
+ // Find the mode that matches the current document URL
+ let documentURLString = documentURL.absoluteString
+ let mode = modes.first { mode in
+ guard let modeURI = mode.uri else { return false }
+ return modeURI == documentURLString || URL(string: modeURI)?.path == documentURL.path
+ }
+
+ return mode
+}
diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift
index e6f274b7..c28f6a66 100644
--- a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift
+++ b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift
@@ -1,12 +1,13 @@
import ActiveApplicationMonitor
import AppKit
+import ChatService
import ChatTab
import ComposableArchitecture
-import GitHubCopilotService
-import SwiftUI
-import PersistMiddleware
import ConversationTab
+import GitHubCopilotService
import HostAppActivator
+import PersistMiddleware
+import SwiftUI
public enum ChatTabBuilderCollection: Equatable {
case folder(title: String, kinds: [ChatTabKind])
@@ -29,7 +30,7 @@ public struct ChatTabKind: Equatable {
public struct WorkspaceIdentifier: Hashable, Codable {
public let path: String
public let username: String
-
+
public init(path: String, username: String) {
self.path = path
self.username = username
@@ -66,7 +67,7 @@ public struct ChatHistory: Equatable {
workspaces[index] = workspace
}
}
-
+
mutating func addWorkspace(_ workspace: ChatWorkspace) {
guard !workspaces.contains(where: { $0.id == workspace.id }) else { return }
workspaces[id: workspace.id] = workspace
@@ -84,10 +85,10 @@ public struct ChatWorkspace: Identifiable, Equatable {
guard let tabId = selectedTabId else { return tabInfo.first }
return tabInfo[id: tabId]
}
-
- public var workspacePath: String { get { id.path} }
- public var username: String { get { id.username } }
-
+
+ public var workspacePath: String { id.path }
+ public var username: String { id.username }
+
private var onTabInfoDeleted: (String) -> Void
public init(
@@ -103,29 +104,29 @@ public struct ChatWorkspace: Identifiable, Equatable {
self.selectedTabId = selectedTabId
self.onTabInfoDeleted = onTabInfoDeleted
}
-
+
/// Walkaround `Equatable` error for `onTabInfoDeleted`
public static func == (lhs: ChatWorkspace, rhs: ChatWorkspace) -> Bool {
lhs.id == rhs.id &&
- lhs.tabInfo == rhs.tabInfo &&
- lhs.tabCollection == rhs.tabCollection &&
- lhs.selectedTabId == rhs.selectedTabId
+ lhs.tabInfo == rhs.tabInfo &&
+ lhs.tabCollection == rhs.tabCollection &&
+ lhs.selectedTabId == rhs.selectedTabId
}
-
+
public mutating func applyLRULimit(maxSize: Int = 5) {
guard tabInfo.count > maxSize else { return }
-
+
// Tabs not selected
let nonSelectedTabs = Array(tabInfo.filter { $0.id != selectedTabId })
let sortedByUpdatedAt = nonSelectedTabs.sorted { $0.updatedAt < $1.updatedAt }
-
+
let tabsToRemove = Array(sortedByUpdatedAt.prefix(tabInfo.count - maxSize))
-
+
// Remove Tabs
for tab in tabsToRemove {
// destroy tab
onTabInfoDeleted(tab.id)
-
+
// remove from workspace
tabInfo.remove(id: tab.id)
}
@@ -175,19 +176,19 @@ public struct ChatPanelFeature {
// case switchToPreviousTab
// case moveChatTab(from: Int, to: Int)
case focusActiveChatTab
-
+
// Chat History
case chatHistoryItemClicked(id: String)
case chatHistoryDeleteButtonClicked(id: String)
case chatTab(id: String, action: ChatTabItem.Action)
-
+
// persist
case saveChatTabInfo([ChatTabInfo?], ChatWorkspace)
case deleteChatTabInfo(id: String, ChatWorkspace)
case restoreWorkspace(ChatWorkspace)
-
+
case syncChatTabInfo([ChatTabInfo?])
-
+
// ChatWorkspace cleanup
case scheduleLRUCleanup(ChatWorkspace)
case performLRUCleanup(ChatWorkspace)
@@ -207,7 +208,8 @@ public struct ChatPanelFeature {
}
public var body: some ReducerOf {
- Reduce { state, action in
+ Reduce {
+ state, action in
switch action {
case .hideButtonClicked:
state.isPanelDisplayed = false
@@ -234,12 +236,13 @@ public struct ChatPanelFeature {
return .none
case .toggleChatPanelDetachedButtonClicked:
- if state.isFullScreen, state.isDetached {
+ if state.isFullScreen,
+ state.isDetached {
return .run { send in
await send(.attachChatPanel)
}
}
-
+
state.isDetached.toggle()
return .none
@@ -251,7 +254,7 @@ public struct ChatPanelFeature {
if state.isFullScreen {
return .run { send in
await MainActor.run { toggleFullScreen() }
- try await Task.sleep(nanoseconds: 1_000_000_000)
+ try await Task.sleep(nanoseconds: 1000000000)
await send(.attachChatPanel)
}
}
@@ -336,18 +339,23 @@ public struct ChatPanelFeature {
}
state.chatHistory.updateHistory(currentChatWorkspace)
return .none
-
+
case let .chatHistoryDeleteButtonClicked(id):
// the current chat should not be deleted
- guard var currentChatWorkspace = state.currentChatWorkspace, id != currentChatWorkspace.selectedTabId else {
+ guard var currentChatWorkspace = state.currentChatWorkspace,
+ id != currentChatWorkspace.selectedTabId else {
return .none
}
+ let CLSConversationID = currentChatWorkspace.tabInfo.first {
+ $0.id == id
+ }?.CLSConversationID
currentChatWorkspace.tabInfo.removeAll { $0.id == id }
state.chatHistory.updateHistory(currentChatWorkspace)
-
+
let chatWorkspace = currentChatWorkspace
return .run { send in
await send(.deleteChatTabInfo(id: id, chatWorkspace))
+ await ToolAutoApprovalManager.shared.clearConversationData(conversationId: CLSConversationID)
}
// case .createNewTapButtonHovered:
@@ -356,11 +364,11 @@ public struct ChatPanelFeature {
case .createNewTapButtonClicked:
return .none // handled in GUI Reducer
-
- case .restoreTabByInfo(_):
+
+ case .restoreTabByInfo:
return .none // handled in GUI Reducer
-
- case .createNewTabByID(_):
+
+ case .createNewTabByID:
return .none // handled in GUI Reducer
case let .tabClicked(id):
@@ -369,37 +377,37 @@ public struct ChatPanelFeature {
// chatTabGroup.selectedTabId = nil
return .none
}
-
+
let (originalTab, currentTab) = currentChatWorkspace.switchTab(to: &chatTabInfo)
state.chatHistory.updateHistory(currentChatWorkspace)
-
+
let workspace = currentChatWorkspace
return .run { send in
await send(.focusActiveChatTab)
await send(.saveChatTabInfo([originalTab, currentTab], workspace))
await send(.syncChatTabInfo([originalTab, currentTab]))
}
-
+
case let .chatHistoryItemClicked(id):
guard var chatWorkspace = state.currentChatWorkspace,
// No Need to swicth selected Tab when already selected
id != chatWorkspace.selectedTabId
else { return .none }
-
+
// Try to find the tab in three places:
// 1. In current workspace's open tabs
let existingTab = chatWorkspace.tabInfo.first(where: { $0.id == id })
-
+
// 2. In persistent storage
let storedTab = existingTab == nil
? ChatTabInfoStore.getByID(id, with: .init(workspacePath: chatWorkspace.workspacePath, username: chatWorkspace.username))
: nil
-
+
if var tabInfo = existingTab ?? storedTab {
// Tab found in workspace or storage - switch to it
let (originalTab, currentTab) = chatWorkspace.switchTab(to: &tabInfo)
state.chatHistory.updateHistory(chatWorkspace)
-
+
let workspace = chatWorkspace
let info = tabInfo
return .run { send in
@@ -407,20 +415,20 @@ public struct ChatPanelFeature {
if storedTab != nil {
await send(.restoreTabByInfo(info: info))
}
-
+
// as converstaion tab is lazy restore
// should restore tab when switching
if let chatTab = chatTabPool.getTab(of: id),
let conversationTab = chatTab as? ConversationTab {
await conversationTab.restoreIfNeeded()
}
-
+
await send(.saveChatTabInfo([originalTab, currentTab], workspace))
-
+
await send(.syncChatTabInfo([originalTab, currentTab]))
}
}
-
+
// 3. Tab not found - create a new one
return .run { send in
await send(.createNewTabByID(id: id))
@@ -428,13 +436,13 @@ public struct ChatPanelFeature {
case var .appendAndSelectTab(tab):
guard var chatWorkspace = state.currentChatWorkspace,
- !chatWorkspace.tabInfo.contains(where: { $0.id == tab.id })
+ !chatWorkspace.tabInfo.contains(where: { $0.id == tab.id })
else { return .none }
-
+
chatWorkspace.tabInfo.append(tab)
let (originalTab, currentTab) = chatWorkspace.switchTab(to: &tab)
state.chatHistory.updateHistory(chatWorkspace)
-
+
let currentChatWorkspace = chatWorkspace
return .run { send in
await send(.focusActiveChatTab)
@@ -449,7 +457,7 @@ public struct ChatPanelFeature {
targetWorkspace.tabInfo.append(tab)
let (originalTab, currentTab) = targetWorkspace.switchTab(to: &tab)
state.chatHistory.updateHistory(targetWorkspace)
-
+
let currentChatWorkspace = targetWorkspace
return .run { send in
await send(.saveChatTabInfo([originalTab, currentTab], currentChatWorkspace))
@@ -514,92 +522,92 @@ public struct ChatPanelFeature {
// }
// MARK: - ChatTabItem action
-
+
case let .chatTab(id, .tabContentUpdated):
guard var currentChatWorkspace = state.currentChatWorkspace,
var info = state.currentChatWorkspace?.tabInfo[id: id]
else { return .none }
-
+
info.updatedAt = .now
currentChatWorkspace.tabInfo[id: id] = info
state.chatHistory.updateHistory(currentChatWorkspace)
-
+
let chatTabInfo = info
let chatWorkspace = currentChatWorkspace
return .run { send in
await send(.saveChatTabInfo([chatTabInfo], chatWorkspace))
}
-
+
case let .chatTab(id, .setCLSConversationID(CID)):
guard var currentChatWorkspace = state.currentChatWorkspace,
var info = state.currentChatWorkspace?.tabInfo[id: id]
else { return .none }
-
+
info.CLSConversationID = CID
currentChatWorkspace.tabInfo[id: id] = info
state.chatHistory.updateHistory(currentChatWorkspace)
-
+
let chatTabInfo = info
let chatWorkspace = currentChatWorkspace
return .run { send in
await send(.saveChatTabInfo([chatTabInfo], chatWorkspace))
}
-
+
case let .chatTab(id, .updateTitle(title)):
guard var currentChatWorkspace = state.currentChatWorkspace,
var info = state.currentChatWorkspace?.tabInfo[id: id],
!info.isTitleSet
else { return .none }
-
+
info.title = title
info.updatedAt = .now
currentChatWorkspace.tabInfo[id: id] = info
state.chatHistory.updateHistory(currentChatWorkspace)
-
+
let chatTabInfo = info
let chatWorkspace = currentChatWorkspace
return .run { send in
await send(.saveChatTabInfo([chatTabInfo], chatWorkspace))
}
-
+
case .chatTab:
return .none
-
+
// MARK: - Persist
+
case let .saveChatTabInfo(chatTabInfos, chatWorkspace):
let toSaveInfo = chatTabInfos.compactMap { $0 }
guard toSaveInfo.count > 0 else { return .none }
let workspacePath = chatWorkspace.workspacePath
let username = chatWorkspace.username
-
+
return .run { _ in
Task(priority: .background) {
ChatTabInfoStore.saveAll(toSaveInfo, with: .init(workspacePath: workspacePath, username: username))
}
}
-
+
case let .deleteChatTabInfo(id, chatWorkspace):
let workspacePath = chatWorkspace.workspacePath
let username = chatWorkspace.username
-
+
ChatTabInfoStore.delete(by: id, with: .init(workspacePath: workspacePath, username: username))
return .none
case var .restoreWorkspace(chatWorkspace):
// chat opened before finishing restoration
if var existChatWorkspace = state.chatHistory.workspaces[id: chatWorkspace.id] {
-
if var selectedChatTabInfo = chatWorkspace.tabInfo.first(where: { $0.id == chatWorkspace.selectedTabId }) {
// Keep the selection state when restoring
selectedChatTabInfo.isSelected = true
chatWorkspace.tabInfo[id: selectedChatTabInfo.id] = selectedChatTabInfo
-
+
// Update the existing workspace's selected tab to match
existChatWorkspace.selectedTabId = selectedChatTabInfo.id
-
+
// merge tab info
existChatWorkspace.tabInfo.append(contentsOf: chatWorkspace.tabInfo)
state.chatHistory.updateHistory(existChatWorkspace)
-
+
let chatTabInfo = selectedChatTabInfo
let workspace = existChatWorkspace
return .run { send in
@@ -608,21 +616,21 @@ public struct ChatPanelFeature {
await send(.scheduleLRUCleanup(workspace))
}
}
-
+
// merge tab info
existChatWorkspace.tabInfo.append(contentsOf: chatWorkspace.tabInfo)
state.chatHistory.updateHistory(existChatWorkspace)
-
+
let workspace = existChatWorkspace
return .run { send in
await send(.scheduleLRUCleanup(workspace))
}
}
-
+
state.chatHistory.addWorkspace(chatWorkspace)
return .none
-
- case .syncChatTabInfo(let tabInfos):
+
+ case let .syncChatTabInfo(tabInfos):
for tabInfo in tabInfos {
guard let tabInfo = tabInfo else { continue }
if let conversationTab = chatTabPool.getTab(of: tabInfo.id) as? ConversationTab {
@@ -630,14 +638,15 @@ public struct ChatPanelFeature {
}
}
return .none
-
+
// MARK: - Clean up ChatWorkspace
- case .scheduleLRUCleanup(let chatWorkspace):
+
+ case let .scheduleLRUCleanup(chatWorkspace):
return .run { send in
await send(.performLRUCleanup(chatWorkspace))
}.cancellable(id: "lru-cleanup-\(chatWorkspace.id)", cancelInFlight: true) // apply built-in race condition prevention
-
- case .performLRUCleanup(var chatWorkspace):
+
+ case var .performLRUCleanup(chatWorkspace):
chatWorkspace.applyLRULimit()
state.chatHistory.updateHistory(chatWorkspace)
return .none
@@ -650,7 +659,6 @@ public struct ChatPanelFeature {
}
extension ChatPanelFeature {
-
func restoreConversationTabIfNeeded(_ id: String) async {
if let chatTab = chatTabPool.getTab(of: id),
let conversationTab = chatTab as? ConversationTab {
@@ -661,30 +669,30 @@ extension ChatPanelFeature {
extension ChatWorkspace {
public mutating func switchTab(to chatTabInfo: inout ChatTabInfo) -> (originalTab: ChatTabInfo?, currentTab: ChatTabInfo) {
- guard self.selectedTabId != chatTabInfo.id else { return (nil, chatTabInfo) }
-
+ guard selectedTabId != chatTabInfo.id else { return (nil, chatTabInfo) }
+
// get original selected tab info to update its isSelected
- var originalTabInfo: ChatTabInfo? = nil
- if self.selectedTabId != nil {
- originalTabInfo = self.tabInfo[id: self.selectedTabId!]
+ var originalTabInfo: ChatTabInfo?
+ if selectedTabId != nil {
+ originalTabInfo = tabInfo[id: selectedTabId!]
}
// fresh selected info in chatWorksapce and tabInfo
- self.selectedTabId = chatTabInfo.id
+ selectedTabId = chatTabInfo.id
originalTabInfo?.isSelected = false
chatTabInfo.isSelected = true
-
+
// update tab back to chatWorkspace
- let isNewTab = self.tabInfo[id: chatTabInfo.id] == nil
- self.tabInfo[id: chatTabInfo.id] = chatTabInfo
+ let isNewTab = tabInfo[id: chatTabInfo.id] == nil
+ tabInfo[id: chatTabInfo.id] = chatTabInfo
if isNewTab {
applyLRULimit()
}
-
+
if let originalTabInfo {
- self.tabInfo[id: originalTabInfo.id] = originalTabInfo
+ tabInfo[id: originalTabInfo.id] = originalTabInfo
}
-
+
return (originalTabInfo, chatTabInfo)
}
}
diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/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/FeatureReducers/NESSuggestionPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/NESSuggestionPanelFeature.swift
new file mode 100644
index 00000000..1bb7dc47
--- /dev/null
+++ b/Core/Sources/SuggestionWidget/FeatureReducers/NESSuggestionPanelFeature.swift
@@ -0,0 +1,62 @@
+import ComposableArchitecture
+import Foundation
+import SwiftUI
+
+@Reducer
+public struct NESSuggestionPanelFeature {
+ @ObservableState
+ public struct State: Equatable {
+ static let baseFontSize: CGFloat = 13
+ static let defaultLineHeight: Double = 18
+
+ var nesContent: NESCodeSuggestionProvider? {
+ didSet { closeNotificationByUser = false }
+ }
+ var colorScheme: ColorScheme = .light
+ var firstLineIndent: Double = 0
+ var lineHeight: Double = Self.defaultLineHeight
+ var lineFontSize: Double {
+ Self.baseFontSize * fontSizeScale
+ }
+ var isPanelDisplayed: Bool = false
+ public var isPanelOutOfFrame: Bool = false
+ var closeNotificationByUser: Bool = false
+ // TODO: handle warnings
+ // var warningMessage: String?
+ // var warningURL: String?
+ var opacity: Double {
+ guard isPanelDisplayed else { return 0 }
+ if isPanelOutOfFrame { return 0 }
+ guard nesContent != nil else { return 0 }
+ return 1
+ }
+ var menuViewOpacity: Double {
+ guard nesContent != nil else { return 0 }
+ guard isPanelDisplayed else { return 0 }
+ return isPanelOutOfFrame ? 0 : 1
+ }
+ var diffViewOpacity: Double { menuViewOpacity }
+ var notificationViewOpacity: Double {
+ guard nesContent != nil else { return 0 }
+ guard isPanelDisplayed else { return 0 }
+ return isPanelOutOfFrame ? 1 : 0
+ }
+ var fontSizeScale: Double {
+ (lineHeight / Self.defaultLineHeight * 100).rounded() / 100
+ }
+ }
+
+ public enum Action: Equatable {
+ case onUserCloseNotification
+ }
+
+ public var body: some ReducerOf {
+ Reduce { state, action in
+ switch action {
+ case .onUserCloseNotification:
+ state.closeNotificationByUser = true
+ return .none
+ }
+ }
+ }
+}
diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift
index e76afbc0..525affb4 100644
--- a/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift
+++ b/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift
@@ -4,6 +4,10 @@ import Foundation
@Reducer
public struct PanelFeature {
+ public enum PanelType {
+ case suggestion, nes, agentConfiguration
+ }
+
@ObservableState
public struct State: Equatable {
public var content: SharedPanelFeature.Content {
@@ -11,6 +15,13 @@ public struct PanelFeature {
set {
sharedPanelState.content = newValue
suggestionPanelState.content = newValue.suggestion
+ }
+ }
+
+ public var nesContent: NESCodeSuggestionProvider? {
+ get { nesSuggestionPanelState.nesContent }
+ set {
+ nesSuggestionPanelState.nesContent = newValue
}
}
@@ -21,6 +32,14 @@ public struct PanelFeature {
// MARK: SuggestionPanel
var suggestionPanelState = SuggestionPanelFeature.State()
+
+ // MARK: NESSuggestionPanel
+
+ public var nesSuggestionPanelState = NESSuggestionPanelFeature.State()
+
+ // MARK: SubAgent
+
+ public var agentConfigurationWidgetState = AgentConfigurationWidgetFeature.State()
var warningMessage: String?
var warningURL: String?
@@ -28,19 +47,26 @@ public struct PanelFeature {
public enum Action: Equatable {
case presentSuggestion
+ case presentNESSuggestion
case presentSuggestionProvider(CodeSuggestionProvider, displayContent: Bool)
+ case presentNESSuggestionProvider(NESCodeSuggestionProvider, displayContent: Bool)
case presentError(String)
case presentPromptToCode(PromptToCodeGroup.PromptToCodeInitialState)
case displayPanelContent
+ case displayNESPanelContent
case expandSuggestion
case discardSuggestion
+ case discardNESSuggestion
case removeDisplayedContent
case switchToAnotherEditorAndUpdateContent
- case hidePanel
- case showPanel
+ case hidePanel(PanelType)
+ case showPanel(PanelType)
+ case onRealtimeNESToggleChanged(Bool)
case sharedPanel(SharedPanelFeature.Action)
case suggestionPanel(SuggestionPanelFeature.Action)
+ case nesSuggestionPanel(NESSuggestionPanelFeature.Action)
+ case agentConfigurationWidget(AgentConfigurationWidgetFeature.Action)
case presentWarning(message: String, url: String?)
case dismissWarning
@@ -59,6 +85,14 @@ public struct PanelFeature {
Scope(state: \.sharedPanelState, action: \.sharedPanel) {
SharedPanelFeature()
}
+
+ Scope(state: \.nesSuggestionPanelState, action: \.nesSuggestionPanel) {
+ NESSuggestionPanelFeature()
+ }
+
+ Scope(state: \.agentConfigurationWidgetState, action: \.agentConfigurationWidget) {
+ AgentConfigurationWidgetFeature()
+ }
Reduce { state, action in
switch action {
@@ -69,6 +103,14 @@ public struct PanelFeature {
else { return }
await send(.presentSuggestionProvider(provider, displayContent: true))
}
+
+ case .presentNESSuggestion:
+ return .run { send in
+ guard let fileURL = await xcodeInspector.safe.activeDocumentURL,
+ let provider = await fetchNESSuggestionProvider(fileURL: fileURL)
+ else { return }
+ await send(.presentNESSuggestionProvider(provider, displayContent: true))
+ }
case let .presentSuggestionProvider(provider, displayContent):
state.content.suggestion = provider
@@ -78,6 +120,15 @@ public struct PanelFeature {
}.animation(.easeInOut(duration: 0.2))
}
return .none
+
+ case let .presentNESSuggestionProvider(provider, displayContent):
+ state.nesContent = provider
+ if displayContent {
+ return .run { send in
+ await send(.displayNESPanelContent)
+ }.animation(.easeInOut(duration: 0.2))
+ }
+ return .none
case let .presentError(errorDescription):
state.content.error = errorDescription
@@ -98,12 +149,22 @@ public struct PanelFeature {
if state.suggestionPanelState.content != nil {
state.suggestionPanelState.isPanelDisplayed = true
}
-
+ return .none
+
+ case .displayNESPanelContent:
+ if state.nesSuggestionPanelState.nesContent != nil {
+ state.nesSuggestionPanelState.isPanelDisplayed = true
+ }
return .none
case .discardSuggestion:
state.content.suggestion = nil
return .none
+
+ case .discardNESSuggestion:
+ state.nesContent = nil
+ return .none
+
case .expandSuggestion:
state.content.isExpanded = true
return .none
@@ -118,15 +179,39 @@ public struct PanelFeature {
)
))
}
- case .hidePanel:
- state.suggestionPanelState.isPanelDisplayed = false
+ case .hidePanel(let panelType):
+ switch panelType {
+ case .suggestion:
+ state.suggestionPanelState.isPanelDisplayed = false
+ case .nes:
+ state.nesSuggestionPanelState.isPanelDisplayed = false
+ case .agentConfiguration:
+ state.agentConfigurationWidgetState.isPanelDisplayed = false
+ }
return .none
- case .showPanel:
- state.suggestionPanelState.isPanelDisplayed = true
+ case .showPanel(let panelType):
+ switch panelType {
+ case .suggestion:
+ state.suggestionPanelState.isPanelDisplayed = true
+ case .nes:
+ state.nesSuggestionPanelState.isPanelDisplayed = true
+ case .agentConfiguration:
+ state.agentConfigurationWidgetState.isPanelDisplayed = true
+ }
return .none
+ case let .onRealtimeNESToggleChanged(isOn):
+ if !isOn {
+ return .run { send in
+ await send(.hidePanel(.nes))
+ await send(.discardNESSuggestion)
+ }
+ }
+ return .none
+
case .removeDisplayedContent:
state.content.error = nil
state.content.suggestion = nil
+ state.nesContent = nil
return .none
case .sharedPanel(.promptToCodeGroup(.activateOrCreatePromptToCode)),
@@ -148,6 +233,12 @@ public struct PanelFeature {
case .suggestionPanel:
return .none
+
+ case .nesSuggestionPanel:
+ return .none
+
+ case .agentConfigurationWidget:
+ return .none
case .presentWarning(let message, let url):
state.warningMessage = message
@@ -172,5 +263,12 @@ public struct PanelFeature {
.suggestionForFile(at: fileURL) else { return nil }
return provider
}
+
+ func fetchNESSuggestionProvider(fileURL: URL) async -> NESCodeSuggestionProvider? {
+ guard let provider = await suggestionWidgetControllerDependency
+ .suggestionWidgetDataSource?
+ .nesSuggestionForFile(at: fileURL) else { return nil }
+ return provider
+ }
}
diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift
index 16d0041d..0bbda7e5 100644
--- a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift
+++ b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift
@@ -111,6 +111,8 @@ public struct WidgetFeature {
case updateColorScheme
case updatePanelStateToMatch(WidgetLocation)
+ case updateNESSuggestionPanelStateToMatch(WidgetLocation)
+ case updateAgentConfigurationWidgetStateToMatch(WidgetLocation)
case updateFocusingDocumentURL
case setFocusingDocumentURL(to: URL?)
case updateKeyWindow(WindowCanBecomeKey)
@@ -391,6 +393,36 @@ public struct WidgetFeature {
.alignPanelTop
return .none
+
+ case let .updateNESSuggestionPanelStateToMatch(widgetLocation):
+
+ guard let nesSuggestionPanelLocation = widgetLocation.nesSuggestionPanelLocation else {
+ state.panelState.nesSuggestionPanelState.isPanelDisplayed = false
+ state.panelState.nesSuggestionPanelState.isPanelOutOfFrame = false
+ return .none
+ }
+
+ let lineFirstCharacterFrame = nesSuggestionPanelLocation.lineFirstCharacterFrame
+ let scrollViewFrame = nesSuggestionPanelLocation.scrollViewFrame
+ if scrollViewFrame.contains(lineFirstCharacterFrame) {
+ state.panelState.nesSuggestionPanelState.isPanelOutOfFrame = false
+ } else {
+ state.panelState.nesSuggestionPanelState.isPanelOutOfFrame = true
+ }
+ state.panelState.nesSuggestionPanelState.lineHeight = nesSuggestionPanelLocation.lineHeight
+
+ return .none
+
+ case let .updateAgentConfigurationWidgetStateToMatch(widgetLocation):
+ guard let agentConfigurationWidgetLocation = widgetLocation.agentConfigurationWidgetLocation else {
+ state.panelState.agentConfigurationWidgetState.isPanelDisplayed = false
+ return .none
+ }
+
+ state.panelState.agentConfigurationWidgetState.isPanelDisplayed = true
+ state.panelState.agentConfigurationWidgetState.lineHeight = agentConfigurationWidgetLocation.lineHeight
+
+ return .none
case let .updateKeyWindow(window):
return .run { _ in
diff --git a/Core/Sources/SuggestionWidget/NES/NESDiffView.swift b/Core/Sources/SuggestionWidget/NES/NESDiffView.swift
new file mode 100644
index 00000000..5d650596
--- /dev/null
+++ b/Core/Sources/SuggestionWidget/NES/NESDiffView.swift
@@ -0,0 +1,150 @@
+import SwiftUI
+import ComposableArchitecture
+import SuggestionBasic
+
+struct NESDiffView: View {
+ var store: StoreOf
+
+ var body: some View {
+ WithPerceptionTracking {
+ if store.isPanelDisplayed,
+ !store.isPanelOutOfFrame,
+ let nesContent = store.nesContent,
+ let originalCodeSnippet = nesContent.getOriginalCodeSnippet()
+ {
+ let nesCode = nesContent.code
+
+ ScrollView(showsIndicators: true) {
+ Group {
+ if nesContent.range.isOneLine && nesCode.components(separatedBy: .newlines).count <= 1 {
+ InlineDiffView(
+ store: store,
+ segments: DiffBuilder.inlineSegments(
+ oldLine: originalCodeSnippet,
+ newLine: nesCode
+ )
+ )
+ } else {
+ LineDiffView(
+ store: store,
+ segments: DiffBuilder.lineSegments(
+ oldContent: originalCodeSnippet,
+ newContent: nesCode
+ )
+ )
+ }
+ }
+ }
+ .padding(.leading, 12 * store.fontSizeScale)
+ .padding(.trailing, 10 * store.fontSizeScale)
+ .padding(.vertical, 4 * store.fontSizeScale)
+ .xcodeStyleFrame()
+ .opacity(store.diffViewOpacity)
+ }
+ }
+ }
+}
+
+
+private struct AccentStrip: View {
+ let store: StoreOf
+ var body: some View {
+ RoundedRectangle(cornerRadius: 4)
+ .fill(.blue)
+ .frame(width: 4 * store.fontSizeScale)
+ }
+}
+
+struct InlineDiffView: View {
+ let store: StoreOf
+ let segments: [DiffSegment]
+
+ var body: some View {
+ HStack(spacing: 0) {
+ AccentStrip(store: store)
+
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 0) {
+ ForEach(Array(segments.enumerated()), id: \.offset) { _, segment in
+ buildSegmentView(segment)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ }
+
+ @ViewBuilder
+ func buildSegmentView(_ segment: DiffSegment) -> some View {
+ Text(verbatim: segment.text.diffDisplayEscaped())
+ .lineLimit(1)
+ .font(.system(size: store.lineFontSize, weight: .medium))
+ .padding(.vertical, 4 * store.fontSizeScale)
+ .background(
+ Rectangle()
+ .fill(segment.backgroundColor)
+ )
+ .alignmentGuide(.firstTextBaseline) { d in
+ d[.firstTextBaseline]
+ }
+ }
+}
+
+
+struct LineDiffView: View {
+ let store: StoreOf
+ let segments: [DiffSegment]
+
+ var body: some View {
+ HStack(spacing: 0) {
+ AccentStrip(store: store)
+
+ VStack(alignment: .leading, spacing: 0) {
+ ForEach(Array(segments.enumerated()), id: \.offset) { _, segment in
+ buildSegmentView(segment)
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
+ }
+
+ @ViewBuilder
+ func buildSegmentView(_ segment: DiffSegment) -> some View {
+ Text(segment.text.diffDisplayEscaped())
+ .font(.system(size: store.lineFontSize, weight: .medium))
+ .multilineTextAlignment(.leading)
+ .padding(.vertical, 4 * store.fontSizeScale)
+ .background(
+ Rectangle()
+ .fill(segment.backgroundColor)
+ )
+ }
+}
+
+
+extension DiffSegment {
+ var backgroundColor: Color {
+ switch change {
+ case .added: return Color("editor.focusedStackFrameHighlightBackground")
+ case .removed: return Color("editorOverviewRuler.inlineChatRemoved")
+ case .unchanged: return .clear
+ }
+ }
+}
+
+private extension String {
+ func diffDisplayEscaped() -> String {
+ var escaped = ""
+ for scalar in unicodeScalars {
+ switch scalar {
+ case "\n": escaped.append("\\n")
+ case "\r": escaped.append("\\r")
+ case "\t": escaped.append("\\t")
+ default: escaped.append(Character(scalar))
+ }
+ }
+ return escaped
+ }
+}
diff --git a/Core/Sources/SuggestionWidget/NES/NESDiffView/NESDiffBuilder.swift b/Core/Sources/SuggestionWidget/NES/NESDiffView/NESDiffBuilder.swift
new file mode 100644
index 00000000..54b9c6d6
--- /dev/null
+++ b/Core/Sources/SuggestionWidget/NES/NESDiffView/NESDiffBuilder.swift
@@ -0,0 +1,136 @@
+import Foundation
+
+struct DiffSegment {
+ enum Change {
+ case unchanged
+ case added
+ case removed
+ }
+ let text: String
+ let change: Change
+}
+
+enum DiffBuilder {
+ static func inlineSegments(oldLine: String, newLine: String) -> [DiffSegment] {
+ let oldTokens = tokenizePreservingWhitespace(oldLine)
+ let newTokens = tokenizePreservingWhitespace(newLine)
+ let condensed = condensedSegments(oldTokens: oldTokens, newTokens: newTokens)
+ return mergeInlineWhitespaceSegments(condensed)
+ }
+
+ static func lineSegments(oldContent: String, newContent: String) -> [DiffSegment] {
+ let oldLines = oldContent.components(separatedBy: .newlines)
+ let newLines = newContent.components(separatedBy: .newlines)
+ return diff(tokensInOld: oldLines, tokensInNew: newLines)
+ }
+
+ private static func tokenizePreservingWhitespace(_ text: String) -> [String] {
+ guard !text.isEmpty else { return [] }
+ // This pattern matches either:
+ // - a sequence of non-whitespace characters (\\S+) followed by optional whitespace (\\s*), or
+ // - a sequence of whitespace characters (\\s+)
+ // This ensures that tokens preserve trailing whitespace, or capture standalone whitespace sequences.
+ let pattern = "\\S+\\s*|\\s+"
+ guard let regex = try? NSRegularExpression(pattern: pattern) else {
+ return [text]
+ }
+ let nsText = text as NSString
+ let fullRange = NSRange(location: 0, length: nsText.length)
+ let matches = regex.matches(in: text, range: fullRange)
+ if matches.isEmpty {
+ return [text]
+ }
+ return matches.map { nsText.substring(with: $0.range) }
+ }
+
+ private static func condensedSegments(oldTokens: [String], newTokens: [String]) -> [DiffSegment] {
+ let raw = diff(tokensInOld: oldTokens, tokensInNew: newTokens)
+ guard var last = raw.first else { return [] }
+ var condensed: [DiffSegment] = []
+ for segment in raw.dropFirst() {
+ if segment.change == last.change {
+ last = DiffSegment(text: last.text + segment.text, change: last.change)
+ } else {
+ condensed.append(last)
+ last = segment
+ }
+ }
+ condensed.append(last)
+ return condensed
+ }
+
+ private static func diff(tokensInOld oldTokens: [String], tokensInNew newTokens: [String]) -> [DiffSegment] {
+ let m = oldTokens.count
+ let n = newTokens.count
+ if m == 0 { return newTokens.map { DiffSegment(text: $0, change: .added) } }
+ if n == 0 { return oldTokens.map { DiffSegment(text: $0, change: .removed) } }
+ var lcs = Array(repeating: Array(repeating: 0, count: n + 1), count: m + 1)
+ for i in 1...m {
+ for j in 1...n {
+ if oldTokens[i - 1] == newTokens[j - 1] {
+ lcs[i][j] = lcs[i - 1][j - 1] + 1
+ } else {
+ lcs[i][j] = max(lcs[i - 1][j], lcs[i][j - 1])
+ }
+ }
+ }
+ var i = m
+ var j = n
+ var result: [DiffSegment] = []
+ while i > 0 && j > 0 {
+ if oldTokens[i - 1] == newTokens[j - 1] {
+ result.append(DiffSegment(text: oldTokens[i - 1], change: .unchanged))
+ i -= 1
+ j -= 1
+ } else if lcs[i - 1][j] > lcs[i][j - 1] {
+ result.append(DiffSegment(text: oldTokens[i - 1], change: .removed))
+ i -= 1
+ } else {
+ result.append(DiffSegment(text: newTokens[j - 1], change: .added))
+ j -= 1
+ }
+ }
+ while i > 0 {
+ result.append(DiffSegment(text: oldTokens[i - 1], change: .removed))
+ i -= 1
+ }
+ while j > 0 {
+ result.append(DiffSegment(text: newTokens[j - 1], change: .added))
+ j -= 1
+ }
+ return result.reversed()
+ }
+
+ private static func mergeInlineWhitespaceSegments(_ segments: [DiffSegment]) -> [DiffSegment] {
+ guard !segments.isEmpty else { return segments }
+ var merged: [DiffSegment] = []
+ var index = 0
+ while index < segments.count {
+ let current = segments[index]
+ switch current.change {
+ case .added, .removed:
+ var combinedText = current.text
+ var lookahead = index + 1
+ while lookahead + 1 < segments.count,
+ segments[lookahead].change == .unchanged,
+ segments[lookahead].text.isWhitespaceOnly,
+ segments[lookahead + 1].change == current.change {
+ combinedText += segments[lookahead].text + segments[lookahead + 1].text
+ lookahead += 2
+ }
+ merged.append(DiffSegment(text: combinedText, change: current.change))
+ index = lookahead
+ case .unchanged:
+ merged.append(current)
+ index += 1
+ }
+ }
+ return merged
+ }
+}
+
+private extension String {
+ var isWhitespaceOnly: Bool {
+ trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ }
+}
diff --git a/Core/Sources/SuggestionWidget/NES/NESMenu/NESCustomMenu.swift b/Core/Sources/SuggestionWidget/NES/NESMenu/NESCustomMenu.swift
new file mode 100644
index 00000000..e20ddbf8
--- /dev/null
+++ b/Core/Sources/SuggestionWidget/NES/NESMenu/NESCustomMenu.swift
@@ -0,0 +1,24 @@
+import Cocoa
+import CGEventOverride
+import Logger
+
+class NESCustomMenu: NSMenu {
+ weak var menuController: NESMenuController?
+
+ override func awakeFromNib() {
+ super.awakeFromNib()
+ }
+
+ override init(title: String) {
+ super.init(title: title)
+ }
+
+ required init(coder: NSCoder) {
+ super.init(coder: coder)
+ }
+
+ private func setupMenuAppearance() {
+ self.showsStateColumn = false
+ self.allowsContextMenuPlugIns = false
+ }
+}
diff --git a/Core/Sources/SuggestionWidget/NES/NESMenu/NESMenuButtonView.swift b/Core/Sources/SuggestionWidget/NES/NESMenu/NESMenuButtonView.swift
new file mode 100644
index 00000000..c9dd8c59
--- /dev/null
+++ b/Core/Sources/SuggestionWidget/NES/NESMenu/NESMenuButtonView.swift
@@ -0,0 +1,94 @@
+import SwiftUI
+import Cocoa
+import Logger
+
+struct NESMenuButtonView: NSViewRepresentable {
+ let menuController: NESMenuController
+ var fontSize: CGFloat
+
+ var buttonImage: NSImage? {
+ NSImage(
+ systemSymbolName: "arrow.right.to.line",
+ accessibilityDescription: "Next Edit Suggestion Menu"
+ )
+ }
+
+ var buttonFont: NSFont {
+ NSFont.systemFont(ofSize: fontSize, weight: .medium)
+ }
+
+ func makeNSView(context: Context) -> NSButton {
+ let button = NSButton(frame: .zero)
+ button.title = ""
+ button.bezelStyle = .shadowlessSquare
+ button.isBordered = false
+ button.imageScaling = .scaleProportionallyDown
+ button.contentTintColor = .white
+ button.imagePosition = .imageOnly
+ button.focusRingType = .none
+ button.target = context.coordinator
+ button.action = #selector(Coordinator.buttonClicked)
+ button.font = buttonFont
+
+ let baseConfig = NSImage.SymbolConfiguration(pointSize: fontSize, weight: .regular)
+ let colorConfig = NSImage.SymbolConfiguration(hierarchicalColor: NSColor.white)
+ button.image = buttonImage?
+ .withSymbolConfiguration(baseConfig)?
+ .withSymbolConfiguration(colorConfig)
+
+ context.coordinator.setupMenu(for: button)
+
+ return button
+ }
+
+ func updateNSView(_ nsView: NSButton, context: Context) {
+ nsView.font = buttonFont
+ if let image = buttonImage {
+ let base = NSImage.SymbolConfiguration(pointSize: fontSize, weight: .regular)
+ let tinted = NSImage.SymbolConfiguration(hierarchicalColor: .white)
+ nsView.image = image.withSymbolConfiguration(base)?.withSymbolConfiguration(tinted)
+ }
+ }
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator(menuController: menuController)
+ }
+
+ class Coordinator: NSObject {
+ let menuController: NESMenuController
+ private weak var button: NSButton?
+
+ init(menuController: NESMenuController) {
+ self.menuController = menuController
+ super.init()
+ }
+
+ func setupMenu(for button: NSButton) {
+ self.button = button
+ }
+
+ @objc func buttonClicked(_ sender: NSButton) {
+ let menu = menuController.createMenu()
+ showMenu(menu, for: sender)
+ }
+
+ private func showMenu(_ menu: NSMenu, for button: NSButton) {
+ // Ensure the button is still in a window before showing the menu
+ guard let window = button.window else {
+ return
+ }
+
+ // Ensure menu is properly positioned and shown
+ let location = NSPoint(x: 0, y: button.bounds.height + 5)
+ let originalLevel = window.level
+ window.level = NSWindow.Level(rawValue: NSWindow.Level.popUpMenu.rawValue + 1)
+ defer { window.level = originalLevel }
+
+ menu.popUp(positioning: nil, at: location, in: button)
+ }
+
+ @objc func menuDidClose(_ menu: NSMenu) { }
+
+ @objc func menuWillOpen(_ menu: NSMenu) { }
+ }
+}
diff --git a/Core/Sources/SuggestionWidget/NES/NESMenu/NESMenuController.swift b/Core/Sources/SuggestionWidget/NES/NESMenu/NESMenuController.swift
new file mode 100644
index 00000000..5ce61b0e
--- /dev/null
+++ b/Core/Sources/SuggestionWidget/NES/NESMenu/NESMenuController.swift
@@ -0,0 +1,227 @@
+import Cocoa
+import ComposableArchitecture
+import SwiftUI
+import HostAppActivator
+
+class NESMenuController: ObservableObject {
+ private static let defaultParagraphTabStopLocation: CGFloat = 180.0
+ private static let titleColor: NSColor = NSColor(Color.secondary)
+ private static let shortcutIconColor: NSColor = NSColor.tertiaryLabelColor
+ static let baseFontSize: CGFloat = 13
+
+ private var menu: NSMenu?
+ var fontSize: CGFloat {
+ didSet { menu = nil }
+ }
+ var fontSizeScale: Double {
+ didSet { menu = nil }
+ }
+ var store: StoreOf
+
+ private var imageSize: NSSize {
+ NSSize(width: self.fontSize, height: self.fontSize)
+ }
+ private var paragraphStyle: NSMutableParagraphStyle {
+ let style = NSMutableParagraphStyle()
+ style.tabStops = [
+ NSTextTab(
+ textAlignment: .right,
+ location: Self.defaultParagraphTabStopLocation * fontSizeScale
+ )
+ ]
+ return style
+ }
+
+ init(fontSize: CGFloat, fontSizeScale: Double, store: StoreOf) {
+ self.fontSize = fontSize
+ self.fontSizeScale = fontSizeScale
+ self.store = store
+ }
+
+ func createMenu() -> NSMenu {
+ let menu = NESCustomMenu(title: "")
+ menu.menuController = self
+
+ menu.font = NSFont.systemFont(ofSize: fontSize, weight: .regular)
+
+ let titleItem = createTitleItem()
+ let settingsItem = createSettingItem()
+ let goToAcceptItem = createGoToAcceptItem()
+ let rejectItem = createRejectItem()
+
+ menu.addItem(titleItem)
+ menu.addItem(NSMenuItem.separator())
+ menu.addItem(settingsItem)
+ menu.addItem(NSMenuItem.separator())
+ menu.addItem(goToAcceptItem)
+ menu.addItem(rejectItem)
+
+ self.menu = menu
+ return menu
+ }
+
+ private func createImage(_ name: String, description accessibilityDescription: String) -> NSImage? {
+ guard let image = NSImage(
+ systemSymbolName: name, accessibilityDescription: accessibilityDescription
+ ) else { return nil }
+
+ image.size = self.imageSize
+ return image
+ }
+
+ private func createParagraphAttributedTitle(_ text: String, helpText: String) -> NSAttributedString {
+ let attributedTitle = NSMutableAttributedString(string: text)
+ attributedTitle.append(NSAttributedString(
+ string: "\t\(helpText)",
+ attributes: [
+ .foregroundColor: Self.shortcutIconColor,
+ .font: NSFont.systemFont(ofSize: fontSize - 1, weight: .regular),
+ .paragraphStyle: paragraphStyle
+ ]
+ ))
+
+ attributedTitle.addAttribute(
+ .paragraphStyle,
+ value: paragraphStyle,
+ range: NSRange(location: 0, length: attributedTitle.length)
+ )
+
+ return attributedTitle
+
+ }
+
+ private func createParagraphAttributedTitle(_ text: String, systemSymbolName: String) -> NSAttributedString {
+ let attributedTitle = NSMutableAttributedString(string: text)
+ attributedTitle.append(NSAttributedString(string: "\t"))
+
+ if let image = createImage(systemSymbolName, description: "\(systemSymbolName) key") {
+ let attachment = NSTextAttachment()
+ attachment.image = image
+
+ let attachmentString = NSMutableAttributedString(attachment: attachment)
+ attachmentString.addAttributes([
+ .foregroundColor: Self.shortcutIconColor,
+ .font: NSFont.systemFont(ofSize: fontSize - 1, weight: .regular),
+ .paragraphStyle: paragraphStyle
+ ], range: NSRange(location: 0, length: attachmentString.length))
+
+ attributedTitle.append(attachmentString)
+ }
+
+ attributedTitle.addAttribute(
+ .paragraphStyle,
+ value: paragraphStyle,
+ range: NSRange(location: 0, length: attributedTitle.length)
+ )
+
+ return attributedTitle
+
+ }
+
+ @objc func handleSettingsAction() {
+ try? launchHostAppAdvancedSettings()
+ }
+
+ @objc func handleGoToAcceptAction() {
+ let state = store.withState { $0 }
+ state.nesContent?.acceptNESSuggestion()
+ }
+
+ @objc func handleRejectAction() {
+ let state = store.withState { $0 }
+ state.nesContent?.rejectNESSuggestion()
+ }
+
+ @objc func handleGetMoreInfoAction() { }
+
+ private func createTitleItem() -> NSMenuItem {
+ let titleItem = NSMenuItem()
+
+ titleItem.isEnabled = false
+
+ let attributedTitle = NSMutableAttributedString(string: "Copilot Next Edit Suggestion")
+ attributedTitle.addAttributes([
+ .foregroundColor: Self.titleColor,
+ .font: NSFont.systemFont(ofSize: fontSize - 1, weight: .medium)
+ ], range: NSRange(location: 0, length: attributedTitle.length))
+
+ titleItem.attributedTitle = attributedTitle
+ return titleItem
+ }
+
+ private func createSettingItem() -> NSMenuItem {
+ let settingsItem = NSMenuItem(
+ title: "Settings",
+ action: #selector(handleSettingsAction),
+ keyEquivalent: ""
+ )
+ settingsItem.target = self
+
+ if let gearImage = NSImage(
+ systemSymbolName: "gearshape",
+ accessibilityDescription: "Settings"
+ ) {
+ gearImage.size = self.imageSize
+ settingsItem.image = gearImage
+ }
+
+ return settingsItem
+ }
+
+ private func createGoToAcceptItem() -> NSMenuItem {
+ let goToAcceptItem = NSMenuItem(
+ title: "Go To / Accept",
+ action: #selector(handleGoToAcceptAction),
+ keyEquivalent: ""
+ )
+ goToAcceptItem.target = self
+
+ let imageSymbolName = "arrow.right.to.line"
+
+ if let arrowImage = createImage(imageSymbolName, description: "Go To or Accept") {
+ goToAcceptItem.image = arrowImage
+ }
+
+ let attributedTitle = createParagraphAttributedTitle("Go To / Accept", systemSymbolName: imageSymbolName)
+ goToAcceptItem.attributedTitle = attributedTitle
+
+ return goToAcceptItem
+ }
+
+ private func createRejectItem() -> NSMenuItem {
+ let rejectItem = NSMenuItem(
+ title: "Reject",
+ action: #selector(handleRejectAction),
+ keyEquivalent: ""
+ )
+ rejectItem.target = self
+
+ if let xImage = createImage("xmark", description: "Reject") {
+ rejectItem.image = xImage
+ }
+
+ let attributedTitle = createParagraphAttributedTitle("Reject", helpText: "Esc")
+ rejectItem.attributedTitle = attributedTitle
+
+ return rejectItem
+ }
+
+ private func createGetMoreInfoItem() -> NSMenuItem {
+ let moreInfoItem = NSMenuItem(
+ title: "Get More Info",
+ action: #selector(handleGetMoreInfoAction),
+ keyEquivalent: ""
+ )
+ moreInfoItem.target = self
+
+ let attributedTitle = NSMutableAttributedString(string: "Get More Info")
+ attributedTitle.addAttributes([
+ .foregroundColor: NSColor.linkColor,
+ .font: NSFont.systemFont(ofSize: fontSize, weight: .medium)
+ ], range: NSRange(location: 0, length: attributedTitle.length))
+
+ moreInfoItem.attributedTitle = attributedTitle
+
+ return moreInfoItem
+ }
+}
diff --git a/Core/Sources/SuggestionWidget/NES/NESMenuView.swift b/Core/Sources/SuggestionWidget/NES/NESMenuView.swift
new file mode 100644
index 00000000..b6ca96f7
--- /dev/null
+++ b/Core/Sources/SuggestionWidget/NES/NESMenuView.swift
@@ -0,0 +1,57 @@
+import ComposableArchitecture
+import SwiftUI
+import Foundation
+import SharedUIComponents
+import XcodeInspector
+import Logger
+
+struct NESMenuView: View {
+ let store: StoreOf
+
+ @State private var menuController: NESMenuController
+
+ init(store: StoreOf) {
+ self.store = store
+ self._menuController = State(
+ initialValue: NESMenuController(
+ fontSize: store.lineFontSize,
+ fontSizeScale: store.fontSizeScale,
+ store: store
+ )
+ )
+ }
+
+ var body: some View {
+ WithPerceptionTracking {
+ let lineHeight = store.lineHeight
+ let fontSizeScale = store.fontSizeScale
+ let fontSize = store.lineFontSize
+ if store.isPanelDisplayed && !store.isPanelOutOfFrame && store.nesContent != nil {
+ NESMenuButtonView(
+ menuController: menuController,
+ fontSize: fontSize
+ )
+ .id("nes-menu-button")
+ .frame(width: lineHeight, height: calcMenuHeight(by: lineHeight))
+ .padding(.horizontal, 3 * fontSizeScale)
+ .padding(.leading, 1 * fontSizeScale)
+ .padding(.vertical, 3 * fontSizeScale)
+ .background(
+ RoundedRectangle(cornerRadius: 6)
+ .fill(Color("LightBluePrimary"))
+ )
+ .opacity(store.menuViewOpacity)
+ .onChange(of: store.lineFontSize) {
+ menuController.fontSize = $0
+ }
+ .onChange(of: store.fontSizeScale) {
+ menuController.fontSizeScale = $0
+ }
+ }
+ }
+ }
+
+ private func calcMenuHeight(by lineHeight: Double) -> Double {
+ return (lineHeight * 2 / 3 * 100).rounded() / 100
+ }
+}
diff --git a/Core/Sources/SuggestionWidget/NES/NESNotificationView.swift b/Core/Sources/SuggestionWidget/NES/NESNotificationView.swift
new file mode 100644
index 00000000..a73ebaae
--- /dev/null
+++ b/Core/Sources/SuggestionWidget/NES/NESNotificationView.swift
@@ -0,0 +1,66 @@
+import SwiftUI
+import ComposableArchitecture
+import Logger
+
+struct NESNotificationView: View {
+ let store: StoreOf
+
+ init(store: StoreOf) {
+ self.store = store
+ }
+
+ var body: some View {
+ WithPerceptionTracking {
+ if store.isPanelOutOfFrame,
+ !store.closeNotificationByUser,
+ store.nesContent != nil {
+
+ let fontSize = store.lineFontSize
+ let scale = store.fontSizeScale
+
+ HStack(spacing: 8) {
+ Image("EditSparkle")
+ .resizable()
+ .scaledToFit()
+ .font(.system(size: calcImageFontSize(fontSize, scale), weight: .medium))
+
+ HStack(spacing: 4 * scale) {
+ Text("Press")
+
+ Text("Tab")
+ .foregroundStyle(.secondary)
+
+ Text("to jump to Next Edit Suggestion")
+ }
+ .font(.system(size: fontSize, weight: .medium))
+
+ Button(action: {
+ store.send(.onUserCloseNotification)
+ }) {
+ Image(systemName: "xmark")
+ }
+ .buttonStyle(.plain)
+ .font(.system(size: calcImageFontSize(fontSize, scale), weight: .medium))
+ }
+ .foregroundStyle(Color(NSColor.controlBackgroundColor))
+ .padding(8)
+ .background(
+ RoundedRectangle(cornerRadius: 4)
+ .fill(.primary)
+ )
+ .shadow(
+ color: Color("NESShadowColor"),
+ radius: 12,
+ x: 0,
+ y: 3
+ )
+ .opacity(store.notificationViewOpacity)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .center)
+ }
+
+ func calcImageFontSize(_ baseFontSize: CGFloat, _ scale: Double) -> CGFloat {
+ return baseFontSize + 2 * scale
+ }
+}
diff --git a/Core/Sources/SuggestionWidget/Providers/CodeSuggestionProvider.swift b/Core/Sources/SuggestionWidget/Providers/CodeSuggestionProvider.swift
index dd50233f..0ec9bb1f 100644
--- a/Core/Sources/SuggestionWidget/Providers/CodeSuggestionProvider.swift
+++ b/Core/Sources/SuggestionWidget/Providers/CodeSuggestionProvider.swift
@@ -4,6 +4,8 @@ import Perception
import SharedUIComponents
import SwiftUI
import XcodeInspector
+import SuggestionBasic
+import WorkspaceSuggestionService
@Perceptible
public final class CodeSuggestionProvider: Equatable {
@@ -58,3 +60,95 @@ public final class CodeSuggestionProvider: Equatable {
}
+@Perceptible
+public final class NESCodeSuggestionProvider: Equatable {
+ public static func == (lhs: NESCodeSuggestionProvider, rhs: NESCodeSuggestionProvider) -> Bool {
+ lhs.code == rhs.code && lhs.language == rhs.language
+ }
+
+ public let fileURL: URL
+ public let code: String
+ public let sourceSnapshot: FilespaceSuggestionSnapshot
+ public let range: CursorRange
+ public let language: String
+
+ @PerceptionIgnored public var onRejectSuggestionTapped: () -> Void
+ @PerceptionIgnored public var onAcceptNESSuggestionTapped: () -> Void
+ @PerceptionIgnored public var onDismissNESSuggestionTapped: () -> Void
+
+ public init(
+ fileURL: URL,
+ code: String,
+ sourceSnapshot: FilespaceSuggestionSnapshot,
+ range: CursorRange,
+ language: String = "",
+ onRejectSuggestionTapped: @escaping () -> Void = {},
+ onAcceptNESSuggestionTapped: @escaping () -> Void = {},
+ onDismissNESSuggestionTapped: @escaping () -> Void = {}
+ ) {
+ self.fileURL = fileURL
+ self.code = code
+ self.sourceSnapshot = sourceSnapshot
+ self.range = range
+ self.language = language
+ self.onRejectSuggestionTapped = onRejectSuggestionTapped
+ self.onAcceptNESSuggestionTapped = onAcceptNESSuggestionTapped
+ self.onDismissNESSuggestionTapped = onDismissNESSuggestionTapped
+ }
+
+ func rejectNESSuggestion() { onRejectSuggestionTapped() }
+ func acceptNESSuggestion() { onAcceptNESSuggestionTapped() }
+ func dismissNESSuggestion() { onDismissNESSuggestionTapped() }
+
+ func getOriginalCodeSnippet() -> String? {
+ /// The lines is from `EditorContent`, the "\n" is kept there.
+ let lines = sourceSnapshot.lines.joined(separator: "").components(separatedBy: .newlines)
+ guard range.start.line >= 0,
+ range.end.line >= range.start.line,
+ range.end.line < lines.count
+ else { return nil }
+
+ // Single line case
+ if range.start.line == range.end.line {
+ let line = lines[range.start.line]
+ let startIndex = calcStartIndex(of: line, by: range)
+ let endIndex = calcEndIndex(of: line, by: range)
+ return String(line[startIndex.. 0)
+ let endIndex = calcEndIndex(of: line, by: range)
+ result.append(String(line[.. String.Index {
+ return line.index(line.startIndex, offsetBy: range.start.character, limitedBy: line.endIndex) ?? line.endIndex
+ }
+
+ private func calcEndIndex(of line: String, by range: CursorRange) -> String.Index {
+ return line.index(line.startIndex, offsetBy: range.end.character, limitedBy: line.endIndex) ?? line.endIndex
+ }
+}
+
diff --git a/Core/Sources/SuggestionWidget/Styles.swift b/Core/Sources/SuggestionWidget/Styles.swift
index a7493e86..6f063016 100644
--- a/Core/Sources/SuggestionWidget/Styles.swift
+++ b/Core/Sources/SuggestionWidget/Styles.swift
@@ -17,6 +17,8 @@ enum Style {
static let codeReviewPanelWidth: Double = 550
static let codeReviewPanelHeight: Double = 450
static let fixPanelToAnnotationSpacing: Double = 1
+ static let nesSuggestionMenuLeadingPadding: Double = 4
+ static let agentConfigurationWidgetLeadingSpacing: Double = 4
}
extension Color {
diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift
index 682d9c79..9462717f 100644
--- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift
+++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift
@@ -332,13 +332,7 @@ extension PromptToCodePanel {
codeForegroundColor: codeForegroundColor
)
}
- .modify {
- if #available(macOS 13.0, *) {
- $0.scrollIndicators(.hidden)
- } else {
- $0
- }
- }
+ .scrollIndicators(.hidden)
}
}
}
diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift
index 06adce2f..366d98d3 100644
--- a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift
+++ b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift
@@ -8,6 +8,7 @@ import Preferences
import SwiftUI
import UserDefaultsObserver
import XcodeInspector
+import SuggestionBasic
@MainActor
public final class SuggestionWidgetController: NSObject {
@@ -48,6 +49,11 @@ public extension SuggestionWidgetController {
store.send(.panel(.presentSuggestion))
}
+
+ func suggestNESCode() {
+ store.send(.panel(.presentNESSuggestion))
+ }
+
func expandSuggestion() {
store.withState { state in
if state.panelState.content.suggestion != nil {
@@ -63,6 +69,14 @@ public extension SuggestionWidgetController {
}
}
}
+
+ func discardNESSuggestion() {
+ store.withState { state in
+ if state.panelState.nesContent != nil {
+ store.send(.panel(.discardNESSuggestion))
+ }
+ }
+ }
#warning("TODO: Make a progress controller that doesn't use TCA.")
func markAsProcessing(_ isProcessing: Bool) {
diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetDataSource.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetDataSource.swift
index f7ad662a..9c691e14 100644
--- a/Core/Sources/SuggestionWidget/SuggestionWidgetDataSource.swift
+++ b/Core/Sources/SuggestionWidget/SuggestionWidgetDataSource.swift
@@ -2,6 +2,7 @@ import Foundation
public protocol SuggestionWidgetDataSource {
func suggestionForFile(at url: URL) async -> CodeSuggestionProvider?
+ func nesSuggestionForFile(at url: URL) async -> NESCodeSuggestionProvider?
}
struct MockWidgetDataSource: SuggestionWidgetDataSource {
@@ -20,5 +21,24 @@ struct MockWidgetDataSource: SuggestionWidgetDataSource {
currentSuggestionIndex: 0
)
}
+
+ func nesSuggestionForFile(at url: URL) async -> NESCodeSuggestionProvider? {
+ return NESCodeSuggestionProvider(
+ fileURL: URL(fileURLWithPath: "the/file/path.swift"),
+ code: """
+ func test() {
+ let x = 1
+ let y = 2
+ let z = x + y
+ }
+ """,
+ sourceSnapshot: .init(
+ lines: [""],
+ cursorPosition: .init(line: 0, character: 0)
+ ),
+ range: .init(startPair: (1, 0), endPair: (2, 0)),
+ language: "swift"
+ )
+ }
}
diff --git a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift
index 45ef1aeb..6f681218 100644
--- a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift
+++ b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift
@@ -4,17 +4,123 @@ import XcodeInspector
import ConversationServiceProvider
public struct WidgetLocation: Equatable {
+ // Indicates from where the widget location generation was triggered
+ enum LocationTrigger {
+ case sourceEditor, xcodeWorkspaceWindow, unknown, otherApp
+
+ var isSourceEditor: Bool { self == .sourceEditor }
+ var isOtherApp: Bool { self == .otherApp }
+ var isFromXcode: Bool { self == .sourceEditor || self == .xcodeWorkspaceWindow}
+ }
+
+ struct NESPanelLocation: Equatable {
+ struct DiffViewConstraints: Equatable {
+ var maxX: CGFloat
+ var y: CGFloat
+ var maxWidth: CGFloat
+ var maxHeight: CGFloat
+ }
+
+ var scrollViewFrame: CGRect
+ var screenFrame: CGRect
+ var lineFirstCharacterFrame: CGRect
+
+ var lineHeight: Double {
+ lineFirstCharacterFrame.height
+ }
+ var menuFrame: CGRect {
+ .init(
+ x: scrollViewFrame.minX + Style.nesSuggestionMenuLeadingPadding,
+ y: screenFrame.height - lineFirstCharacterFrame.maxY,
+ width: lineFirstCharacterFrame.width,
+ height: lineHeight
+ )
+ }
+
+ var availableHeight: CGFloat? {
+ guard scrollViewFrame.contains(lineFirstCharacterFrame) else {
+ return nil
+ }
+ return scrollViewFrame.maxY - lineFirstCharacterFrame.minY
+ }
+
+ var availableWidth: CGFloat {
+ return scrollViewFrame.width / 2
+ }
+
+ func calcDiffViewFrame(contentSize: CGSize) -> CGRect? {
+ guard scrollViewFrame.contains(lineFirstCharacterFrame) else {
+ return nil
+ }
+
+ let availableWidth = max(0, scrollViewFrame.width / 2)
+ let availableHeight = max(0, scrollViewFrame.maxY - lineFirstCharacterFrame.minY)
+ let preferredWidth = max(contentSize.width, 1)
+ let preferredHeight = max(contentSize.height, lineHeight)
+
+ let width = availableWidth > 0 ? min(preferredWidth, availableWidth) : preferredWidth
+ let height = availableHeight > 0 ? min(preferredHeight, availableHeight) : preferredHeight
+
+ return .init(
+ x: scrollViewFrame.maxX - width - Style.nesSuggestionMenuLeadingPadding,
+ y: screenFrame.height - lineFirstCharacterFrame.minY - height,
+ width: width,
+ height: height
+ )
+ }
+ }
+
+ struct AgentConfigurationWidgetLocation: Equatable {
+ var firstLineFrame: CGRect
+ var scrollViewRect: CGRect
+ var screenFrame: CGRect
+ var textEndX: CGFloat
+
+ var lineHeight: CGFloat {
+ firstLineFrame.height
+ }
+
+ func getWidgetFrame(_ originalFrame: NSRect) -> NSRect {
+ let width = originalFrame.width
+ let height = originalFrame.height
+ let lineCenter = firstLineFrame.minY + firstLineFrame.height / 2
+ let panelHalfHeight = originalFrame.height / 2
+
+ return .init(
+ x: textEndX + Style.agentConfigurationWidgetLeadingSpacing,
+ y: screenFrame.maxY - lineCenter - panelHalfHeight + screenFrame.minY,
+ width: width,
+ height: height
+ )
+ }
+ }
+
struct PanelLocation: Equatable {
var frame: CGRect
var alignPanelTop: Bool
var firstLineIndent: Double?
var lineHeight: Double?
}
-
+
var widgetFrame: CGRect
var tabFrame: CGRect
var defaultPanelLocation: PanelLocation
var suggestionPanelLocation: PanelLocation?
+ var nesSuggestionPanelLocation: NESPanelLocation?
+ var locationTrigger: LocationTrigger = .unknown
+ var agentConfigurationWidgetLocation: AgentConfigurationWidgetLocation?
+
+ mutating func setNESSuggestionPanelLocation(_ location: NESPanelLocation?) {
+ self.nesSuggestionPanelLocation = location
+ }
+
+ mutating func setLocationTrigger(_ trigger: LocationTrigger) {
+ self.locationTrigger = trigger
+ }
+
+ mutating func setAgentConfigurationWidgetLocation(_ location: AgentConfigurationWidgetLocation?) {
+ self.agentConfigurationWidgetLocation = location
+ }
}
enum UpdateLocationStrategy {
@@ -30,10 +136,10 @@ enum UpdateLocationStrategy {
) -> WidgetLocation {
guard let selectedRange: AXValue = try? editor
.copyValue(key: kAXSelectedTextRangeAttribute),
- let rect: AXValue = try? editor.copyParameterizedValue(
+ let rect: AXValue = try? editor.copyParameterizedValue(
key: kAXBoundsForRangeParameterizedAttribute,
parameters: selectedRange
- )
+ )
else {
return FixedToBottom().framesForWindows(
editorFrame: editorFrame,
@@ -63,7 +169,7 @@ enum UpdateLocationStrategy {
)
}
}
-
+
struct FixedToBottom {
func framesForWindows(
editorFrame: CGRect,
@@ -86,7 +192,7 @@ enum UpdateLocationStrategy {
)
}
}
-
+
struct HorizontalMovable {
func framesForWindows(
y: CGFloat,
@@ -109,34 +215,34 @@ enum UpdateLocationStrategy {
mainScreen.frame.height - editorFrame.minY - Style.widgetHeight - Style
.widgetPadding
)
-
+
var proposedAnchorFrameOnTheRightSide = CGRect(
x: editorFrame.maxX - Style.widgetPadding,
y: y,
width: 0,
height: 0
)
-
+
let widgetFrameOnTheRightSide = CGRect(
x: editorFrame.maxX - Style.widgetPadding - Style.widgetWidth,
y: y,
width: Style.widgetWidth,
height: Style.widgetHeight
)
-
+
if !hideCircularWidget {
proposedAnchorFrameOnTheRightSide = widgetFrameOnTheRightSide
}
-
+
let proposedPanelX = proposedAnchorFrameOnTheRightSide.maxX
- + Style.widgetPadding * 2
- - editorFrameExpendedSize.width
+ + Style.widgetPadding * 2
+ - editorFrameExpendedSize.width
let putPanelToTheRight = {
if editorFrame.size.width >= preferredInsideEditorMinWidth { return false }
return activeScreen.frame.maxX > proposedPanelX + Style.panelWidth
}()
let alignPanelTopToAnchor = fixedAlignment ?? (y > activeScreen.frame.midY)
-
+
let chatPanelFrame = getChatPanelFrame(mainScreen)
if putPanelToTheRight {
@@ -144,12 +250,12 @@ enum UpdateLocationStrategy {
let tabFrame = CGRect(
x: anchorFrame.origin.x,
y: alignPanelTopToAnchor
- ? anchorFrame.minY - Style.widgetHeight - Style.widgetPadding
- : anchorFrame.maxY + Style.widgetPadding,
+ ? anchorFrame.minY - Style.widgetHeight - Style.widgetPadding
+ : anchorFrame.maxY + Style.widgetPadding,
width: Style.widgetWidth,
height: Style.widgetHeight
)
-
+
return .init(
widgetFrame: widgetFrameOnTheRightSide,
tabFrame: tabFrame,
@@ -166,22 +272,22 @@ enum UpdateLocationStrategy {
width: 0,
height: 0
)
-
+
let widgetFrameOnTheLeftSide = CGRect(
x: editorFrame.minX + Style.widgetPadding,
y: proposedAnchorFrameOnTheRightSide.origin.y,
width: Style.widgetWidth,
height: Style.widgetHeight
)
-
+
if !hideCircularWidget {
proposedAnchorFrameOnTheLeftSide = widgetFrameOnTheLeftSide
}
-
+
let proposedPanelX = proposedAnchorFrameOnTheLeftSide.minX
- - Style.widgetPadding * 2
- - Style.panelWidth
- + editorFrameExpendedSize.width
+ - Style.widgetPadding * 2
+ - Style.panelWidth
+ + editorFrameExpendedSize.width
let putAnchorToTheLeft = {
if editorFrame.size.width >= preferredInsideEditorMinWidth {
if editorFrame.maxX <= activeScreen.frame.maxX {
@@ -190,14 +296,14 @@ enum UpdateLocationStrategy {
}
return proposedPanelX > activeScreen.frame.minX
}()
-
+
if putAnchorToTheLeft {
let anchorFrame = proposedAnchorFrameOnTheLeftSide
let tabFrame = CGRect(
x: anchorFrame.origin.x,
y: alignPanelTopToAnchor
- ? anchorFrame.minY - Style.widgetHeight - Style.widgetPadding
- : anchorFrame.maxY + Style.widgetPadding,
+ ? anchorFrame.minY - Style.widgetHeight - Style.widgetPadding
+ : anchorFrame.maxY + Style.widgetPadding,
width: Style.widgetWidth,
height: Style.widgetHeight
)
@@ -231,7 +337,7 @@ enum UpdateLocationStrategy {
}
}
}
-
+
struct NearbyTextCursor {
func framesForSuggestionWindow(
editorFrame: CGRect,
@@ -242,35 +348,37 @@ enum UpdateLocationStrategy {
) -> WidgetLocation.PanelLocation? {
guard let selectionFrame = UpdateLocationStrategy
.getSelectionFirstLineFrame(editor: editor) else { return nil }
-
+
// hide it when the line of code is outside of the editor visible rect
if selectionFrame.maxY < editorFrame.minY || selectionFrame.minY > editorFrame.maxY {
return nil
}
-
+
+ let lineHeight: Double = selectionFrame.height
+ let selectionMinY = selectionFrame.minY
// Always place suggestion window at cursor position.
return .init(
frame: .init(
x: editorFrame.minX,
- y: mainScreen.frame.height - selectionFrame.minY - Style.inlineSuggestionMaxHeight + Style.inlineSuggestionPadding,
+ y: mainScreen.frame.height - selectionMinY - Style.inlineSuggestionMaxHeight + Style.inlineSuggestionPadding,
width: editorFrame.width,
height: Style.inlineSuggestionMaxHeight
),
alignPanelTop: true,
firstLineIndent: selectionFrame.maxX - editorFrame.minX - Style.inlineSuggestionPadding,
- lineHeight: selectionFrame.height
+ lineHeight: lineHeight
)
}
}
-
+
/// Get the frame of the selection.
static func getSelectionFrame(editor: AXUIElement) -> CGRect? {
guard let selectedRange: AXValue = try? editor
.copyValue(key: kAXSelectedTextRangeAttribute),
- let rect: AXValue = try? editor.copyParameterizedValue(
+ let rect: AXValue = try? editor.copyParameterizedValue(
key: kAXBoundsForRangeParameterizedAttribute,
parameters: selectedRange
- )
+ )
else {
return nil
}
@@ -279,36 +387,36 @@ enum UpdateLocationStrategy {
guard found else { return nil }
return selectionFrame
}
-
+
/// Get the frame of the first line of the selection.
static func getSelectionFirstLineFrame(editor: AXUIElement) -> CGRect? {
// Find selection range rect
guard let selectedRange: AXValue = try? editor
.copyValue(key: kAXSelectedTextRangeAttribute),
- let rect: AXValue = try? editor.copyParameterizedValue(
+ let rect: AXValue = try? editor.copyParameterizedValue(
key: kAXBoundsForRangeParameterizedAttribute,
parameters: selectedRange
- )
+ )
else {
return nil
}
var selectionFrame: CGRect = .zero
let found = AXValueGetValue(rect, .cgRect, &selectionFrame)
guard found else { return nil }
-
+
var firstLineRange: CFRange = .init()
let foundFirstLine = AXValueGetValue(selectedRange, .cfRange, &firstLineRange)
firstLineRange.length = 0
-
- #warning(
- "FIXME: When selection is too low and out of the screen, the selection range becomes something else."
+
+#warning(
+ "FIXME: When selection is too low and out of the screen, the selection range becomes something else."
)
-
+
if foundFirstLine,
let firstLineSelectionRange = AXValueCreate(.cfRange, &firstLineRange),
let firstLineRect: AXValue = try? editor.copyParameterizedValue(
- key: kAXBoundsForRangeParameterizedAttribute,
- parameters: firstLineSelectionRange
+ key: kAXBoundsForRangeParameterizedAttribute,
+ parameters: firstLineSelectionRange
)
{
var firstLineFrame: CGRect = .zero
@@ -317,7 +425,7 @@ enum UpdateLocationStrategy {
selectionFrame = firstLineFrame
}
}
-
+
return selectionFrame
}
@@ -365,10 +473,10 @@ public struct CodeReviewLocationStrategy {
currentLines: [String]
) -> Int {
let difference = currentLines.difference(from: originalLines)
-
+
let targetIndex = originalLineNumber
var adjustment = 0
-
+
for change in difference {
switch change {
case .insert(let offset, _, _):
@@ -383,19 +491,19 @@ public struct CodeReviewLocationStrategy {
}
}
}
-
+
return targetIndex + adjustment
}
-
+
static func getCurrentLineFrame(
- editor: AXUIElement,
+ editor: AXUIElement,
currentContent: String,
- comment: ReviewComment,
+ comment: ReviewComment,
originalContent: String
) -> (lineNumber: Int?, lineFrame: CGRect?) {
let originalLines = originalContent.components(separatedBy: .newlines)
let currentLines = currentContent.components(separatedBy: .newlines)
-
+
let originalLineNumber = comment.range.end.line
let currentLineNumber = calculateCurrentLineNumber(
for: originalLineNumber,
@@ -410,3 +518,108 @@ public struct CodeReviewLocationStrategy {
return (currentLineNumber, rect)
}
}
+
+public struct NESPanelLocationStrategy {
+ static func getNESPanelLocation(
+ maybeEditor: AXUIElement,
+ state: WidgetFeature.State
+ ) -> WidgetLocation.NESPanelLocation? {
+ guard let sourceEditor = maybeEditor.findSourceEditorElement(shouldRetry: false),
+ let editorContent: String = try? sourceEditor.copyValue(key: kAXValueAttribute),
+ let nesContent = state.panelState.nesContent,
+ let screen = NSScreen.screens.first(where: { $0.frame.origin == .zero })
+ else {
+ return nil
+ }
+
+ let startLine = nesContent.range.start.line
+ guard let lineFirstCharacterFrame = LocationStrategyHelper.getLineFrame(
+ startLine,
+ in: sourceEditor,
+ with: editorContent.components(separatedBy: .newlines),
+ length: 1
+ ) else {
+ return nil
+ }
+
+ guard let scrollViewFrame = sourceEditor.parent?.rect else {
+ return nil
+ }
+
+ return .init(
+ scrollViewFrame: scrollViewFrame,
+ screenFrame: screen.frame,
+ lineFirstCharacterFrame: lineFirstCharacterFrame
+ )
+ }
+}
+
+public struct AgentConfigurationWidgetLocationStrategy {
+ static func getAgentConfigurationWidgetLocation(
+ maybeEditor: AXUIElement,
+ screen: NSScreen
+ ) -> WidgetLocation.AgentConfigurationWidgetLocation? {
+ guard let sourceEditorElement = maybeEditor.findSourceEditorElement(shouldRetry: false),
+ let editorContent: String = try? sourceEditorElement.copyValue(key: kAXValueAttribute),
+ let scrollViewRect = sourceEditorElement.parent?.rect
+ else {
+ return nil
+ }
+
+ // Get the editor content to access lines
+ let lines = editorContent.components(separatedBy: .newlines)
+ guard !lines.isEmpty else {
+ return nil
+ }
+
+ // Get the frame of the first line (line 0)
+ guard let firstLineFrame = LocationStrategyHelper.getLineFrame(
+ 0,
+ in: sourceEditorElement,
+ with: [lines[0]]
+ ) else {
+ return nil
+ }
+
+ // Check if the first line is visible within the scroll view
+ guard firstLineFrame.width > 0, firstLineFrame.height > 0,
+ scrollViewRect.contains(firstLineFrame)
+ else {
+ return nil
+ }
+
+ // Get the actual text content width (excluding trailing whitespace)
+ let firstLineText = lines[0].trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
+ let textEndX: CGFloat
+
+ if !firstLineText.isEmpty {
+ // Calculate character position for the end of the trimmed text
+ let textLength = firstLineText.count
+ var range = CFRange(location: 0, length: textLength)
+
+ if let rangeValue = AXValueCreate(AXValueType.cfRange, &range),
+ let boundsValue: AXValue = try? sourceEditorElement.copyParameterizedValue(
+ key: kAXBoundsForRangeParameterizedAttribute,
+ parameters: rangeValue
+ ) {
+ var textRect = CGRect.zero
+ if AXValueGetValue(boundsValue, .cgRect, &textRect) {
+ textEndX = textRect.maxX
+ } else {
+ textEndX = firstLineFrame.minX
+ }
+ } else {
+ textEndX = firstLineFrame.minX
+ }
+ } else {
+ textEndX = firstLineFrame.minX
+ }
+
+ return .init(
+ firstLineFrame: firstLineFrame,
+ scrollViewRect: scrollViewRect,
+ screenFrame: screen.frame,
+ textEndX: textEndX
+ )
+ }
+}
diff --git a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift
index cb75004c..e3d19b8a 100644
--- a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift
+++ b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift
@@ -61,8 +61,9 @@ actor WidgetWindowsController: NSObject {
}.store(in: &cancellable)
xcodeInspector.$focusedEditor.sink { [weak self] editor in
- Task { @MainActor [weak self] in
+ Task { @MainActor [weak self] in
self?.store.send(.fixErrorPanel(.onFocusedEditorChanged(editor)))
+ self?.store.send(.panel(.agentConfigurationWidget(.onFocusedEditorChanged(editor))))
}
guard let editor else { return }
@@ -76,7 +77,7 @@ actor WidgetWindowsController: NSObject {
}.store(in: &cancellable)
xcodeInspector.$activeDocumentURL.sink { [weak self] url in
- Task { [weak self] in
+ Task { [weak self] in
await self?.updateCodeReviewWindowLocation(.onActiveDocumentURLChanged)
_ = await MainActor.run { [weak self] in
self?.store.send(.codeReviewPanel(.onActiveDocumentURLChanged(url)))
@@ -96,6 +97,12 @@ actor WidgetWindowsController: NSObject {
// Observe state change of fix error
setupFixErrorPanelObservers()
+
+ // Observer state change for NES
+ setupNESSuggestionPanelObservers()
+
+ // Observe feature flags
+ setupFeatureFlagObservers()
}
private func setupCodeReviewPanelObservers() {
@@ -233,42 +240,24 @@ private extension WidgetWindowsController {
let valueChange = await editor.axNotifications.notifications()
.filter { $0.kind == .valueChanged }
- if #available(macOS 13.0, *) {
- for await notification in merge(
- scroll,
- selectionRangeChange.debounce(for: Duration.milliseconds(0)),
- valueChange.debounce(for: Duration.milliseconds(100))
- ) {
- guard await xcodeInspector.safe.latestActiveXcode != nil else { return }
- try Task.checkCancellation()
-
- // for better looking
- if notification.kind == .scrollPositionChanged {
- await hideSuggestionPanelWindow()
- }
+ for await notification in merge(
+ scroll,
+ selectionRangeChange.debounce(for: Duration.milliseconds(0)),
+ valueChange.debounce(for: Duration.milliseconds(100))
+ ) {
+ guard await xcodeInspector.safe.latestActiveXcode != nil else { return }
+ try Task.checkCancellation()
- updateWindowLocation(animated: false, immediately: false)
- updateWindowOpacity(immediately: false)
- await updateCodeReviewWindowLocation(.onSourceEditorNotification(notification))
-
- await handleFixErrorEditorNotification(notification: notification)
+ // for better looking
+ if notification.kind == .scrollPositionChanged {
+ await hideSuggestionPanelWindow()
}
- } else {
- for await notification in merge(selectionRangeChange, scroll, valueChange) {
- guard await xcodeInspector.safe.latestActiveXcode != nil else { return }
- try Task.checkCancellation()
- // for better looking
- if notification.kind == .scrollPositionChanged {
- await hideSuggestionPanelWindow()
- }
+ updateWindowLocation(animated: false, immediately: false)
+ updateWindowOpacity(immediately: false)
+ await updateCodeReviewWindowLocation(.onSourceEditorNotification(notification))
- updateWindowLocation(animated: false, immediately: false)
- updateWindowOpacity(immediately: false)
- await updateCodeReviewWindowLocation(.onSourceEditorNotification(notification))
-
- await handleFixErrorEditorNotification(notification: notification)
- }
+ await handleFixErrorEditorNotification(notification: notification)
}
}
}
@@ -302,7 +291,7 @@ extension WidgetWindowsController {
@MainActor
func hideSuggestionPanelWindow() {
windows.suggestionPanelWindow.alphaValue = 0
- send(.panel(.hidePanel))
+ send(.panel(.hidePanel(.suggestion)))
}
@MainActor
@@ -318,9 +307,9 @@ extension WidgetWindowsController {
windows.codeReviewPanelWindow.orderFrontRegardless()
}
- func generateWidgetLocation() -> WidgetLocation? {
+ func generateWidgetLocation(_ state: WidgetFeature.State) -> WidgetLocation {
// Default location when no active application/window
- let defaultLocation = generateDefaultLocation()
+ var defaultLocation = generateDefaultLocation()
if let application = xcodeInspector.latestActiveXcode?.appElement {
if let focusElement = xcodeInspector.focusedEditor?.element,
@@ -333,6 +322,12 @@ extension WidgetWindowsController {
.value(for: \.suggestionWidgetPositionMode)
let suggestionMode = UserDefaults.shared
.value(for: \.suggestionPresentationMode)
+
+ let nesPanelLocation: WidgetLocation.NESPanelLocation? = NESPanelLocationStrategy.getNESPanelLocation(maybeEditor: parent, state: state)
+ let locationTrigger: WidgetLocation.LocationTrigger = .sourceEditor
+ let agentConfigurationWidgetLocation = AgentConfigurationWidgetLocationStrategy.getAgentConfigurationWidgetLocation(
+ maybeEditor: parent, screen: screen
+ )
switch positionMode {
case .fixedToBottom:
@@ -341,6 +336,9 @@ extension WidgetWindowsController {
mainScreen: screen,
activeScreen: firstScreen
)
+ result.setNESSuggestionPanelLocation(nesPanelLocation)
+ result.setLocationTrigger(locationTrigger)
+ result.setAgentConfigurationWidgetLocation(agentConfigurationWidgetLocation)
switch suggestionMode {
case .nearbyTextCursor:
result.suggestionPanelLocation = UpdateLocationStrategy
@@ -362,6 +360,9 @@ extension WidgetWindowsController {
activeScreen: firstScreen,
editor: focusElement
)
+ result.setNESSuggestionPanelLocation(nesPanelLocation)
+ result.setLocationTrigger(locationTrigger)
+ result.setAgentConfigurationWidgetLocation(agentConfigurationWidgetLocation)
switch suggestionMode {
case .nearbyTextCursor:
result.suggestionPanelLocation = UpdateLocationStrategy
@@ -379,19 +380,20 @@ extension WidgetWindowsController {
}
} else if var window = application.focusedWindow,
var frame = application.focusedWindow?.rect,
- !["menu bar", "menu bar item"].contains(window.description),
+ !window.isXcodeMenuBar,
frame.size.height > 300,
let screen = NSScreen.screens.first(where: { $0.frame.origin == .zero }),
let firstScreen = NSScreen.main
{
- if ["open_quickly"].contains(window.identifier)
- || ["alert"].contains(window.label)
+ if window.isXcodeOpenQuickly
+ || window.isXcodeAlert
{
// fallback to use workspace window
guard let workspaceWindow = application.windows
- .first(where: { $0.identifier == "Xcode.WorkspaceWindow" }),
+ .first(where: { $0.isXcodeWorkspaceWindow }),
let rect = workspaceWindow.rect
else {
+ defaultLocation.setLocationTrigger(.otherApp)
return defaultLocation
}
@@ -400,7 +402,7 @@ extension WidgetWindowsController {
}
var expendedSize = CGSize.zero
- if ["Xcode.WorkspaceWindow"].contains(window.identifier) {
+ if window.isXcodeWorkspaceWindow {
// extra padding to bottom so buttons won't be covered
frame.size.height -= 40
} else {
@@ -411,13 +413,16 @@ extension WidgetWindowsController {
expendedSize.height += Style.widgetPadding
}
- return UpdateLocationStrategy.FixedToBottom().framesForWindows(
+ var result = UpdateLocationStrategy.FixedToBottom().framesForWindows(
editorFrame: frame,
mainScreen: screen,
activeScreen: firstScreen,
preferredInsideEditorMinWidth: 9_999_999_999, // never
editorFrameExpendedSize: expendedSize
)
+ result.setLocationTrigger(.xcodeWorkspaceWindow)
+
+ return result
}
}
return defaultLocation
@@ -434,12 +439,15 @@ extension WidgetWindowsController {
frame: chatPanelFrame,
alignPanelTop: false
),
- suggestionPanelLocation: nil
+ suggestionPanelLocation: nil,
+ nesSuggestionPanelLocation: nil
)
}
func updatePanelState(_ location: WidgetLocation) async {
await send(.updatePanelStateToMatch(location))
+ await send(.updateNESSuggestionPanelStateToMatch(location))
+ await send(.updateAgentConfigurationWidgetStateToMatch(location))
}
func updateWindowOpacity(immediately: Bool) {
@@ -475,8 +483,13 @@ extension WidgetWindowsController {
/// We need this to hide the windows when Xcode is minimized.
let noFocus = application.focusedWindow == nil
windows.sharedPanelWindow.alphaValue = noFocus ? 0 : 1
- send(.panel(noFocus ? .hidePanel : .showPanel))
+ send(.panel(noFocus ? .hidePanel(.suggestion) : .showPanel(.suggestion)))
windows.suggestionPanelWindow.alphaValue = noFocus ? 0 : 1
+ send(.panel(noFocus ? .hidePanel(.nes) : .showPanel(.nes)))
+ applyOpacityForNESWindows(by: noFocus)
+ send(.panel(noFocus ? .hidePanel(.agentConfiguration) : .showPanel(.agentConfiguration)))
+ applyOpacityForAgentConfigurationWidget(by: noFocus)
+ windows.nesNotificationWindow.alphaValue = noFocus ? 0 : 1
windows.widgetWindow.alphaValue = noFocus ? 0 : 1
windows.toastWindow.alphaValue = noFocus ? 0 : 1
@@ -498,8 +511,13 @@ extension WidgetWindowsController {
let previousAppIsXcode = previousActiveApplication?.isXcode ?? false
- send(.panel(noFocus ? .hidePanel : .showPanel))
+ send(.panel(noFocus ? .hidePanel(.suggestion) : .showPanel(.suggestion)))
windows.sharedPanelWindow.alphaValue = noFocus ? 0 : 1
+ send(.panel(noFocus ? .hidePanel(.nes) : .showPanel(.nes)))
+ applyOpacityForNESWindows(by: noFocus)
+ send(.panel(noFocus ? .hidePanel(.agentConfiguration) : .showPanel(.agentConfiguration)))
+ applyOpacityForAgentConfigurationWidget(by: noFocus)
+ windows.nesNotificationWindow.alphaValue = noFocus ? 0 : 1
windows.suggestionPanelWindow.alphaValue = noFocus ? 0 : 1
windows.widgetWindow.alphaValue = if noFocus {
0
@@ -518,6 +536,10 @@ extension WidgetWindowsController {
} else {
windows.sharedPanelWindow.alphaValue = 0
windows.suggestionPanelWindow.alphaValue = 0
+ windows.nesMenuWindow.alphaValue = 0
+ windows.nesDiffWindow.alphaValue = 0
+ applyOpacityForAgentConfigurationWidget()
+ windows.nesNotificationWindow.alphaValue = 0
windows.widgetWindow.alphaValue = 0
windows.toastWindow.alphaValue = 0
if !isChatPanelDetached {
@@ -595,7 +617,7 @@ extension WidgetWindowsController {
func update() async {
let state = store.withState { $0 }
let isChatPanelDetached = state.chatPanelState.isDetached
- guard let widgetLocation = await generateWidgetLocation() else { return }
+ let widgetLocation = await generateWidgetLocation(state)
await updatePanelState(widgetLocation)
windows.widgetWindow.setFrame(
@@ -622,6 +644,29 @@ extension WidgetWindowsController {
)
}
+ if let nesPanelLocation = widgetLocation.nesSuggestionPanelLocation {
+ windows.nesMenuWindow.setFrame(
+ nesPanelLocation.menuFrame,
+ display: false,
+ animate: animated
+ )
+ await updateNESDiffWindowFrame(
+ nesPanelLocation,
+ animated: animated,
+ trigger: widgetLocation.locationTrigger
+ )
+
+ await updateNESNotificationWindowFrame(nesPanelLocation, animated: animated)
+ }
+
+ if let agentConfigurationWidgetLocation = widgetLocation.agentConfigurationWidgetLocation {
+ windows.agentConfigurationWidgetWindow.setFrame(
+ agentConfigurationWidgetLocation.getWidgetFrame(windows.agentConfigurationWidgetWindow.frame),
+ display: false,
+ animate: animated
+ )
+ }
+
let isAttachedToXcodeEnabled = UserDefaults.shared.value(for: \.autoAttachChatToXcode)
if isAttachedToXcodeEnabled {
// update in `updateAttachedChatWindowLocation`
@@ -799,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
}
@@ -808,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 }
@@ -1009,6 +1054,92 @@ public final class WidgetWindows {
it.setIsVisible(true)
return it
}()
+
+ @MainActor
+ lazy var nesMenuWindow = {
+ let it = CanBecomeKeyWindow(
+ contentRect: .init(x: 0, y: 0, width: Style.panelWidth, height: Style.panelHeight),
+ styleMask: .borderless,
+ backing: .buffered,
+ defer: false
+ )
+ it.isOpaque = false
+ it.backgroundColor = .clear
+ it.level = widgetLevel(2)
+ it.collectionBehavior = [.fullScreenAuxiliary, .transient, .canJoinAllSpaces]
+ it.hasShadow = false
+ it.contentView = NSHostingView(
+ rootView: NESMenuView(
+ store: store.scope(
+ state: \.panelState,
+ action: \.panel
+ ).scope(
+ state: \.nesSuggestionPanelState,
+ action: \.nesSuggestionPanel
+ )
+ )
+ )
+ it.canBecomeKeyChecker = { false }
+ it.setIsVisible(true)
+ return it
+ }()
+
+ @MainActor
+ lazy var nesDiffWindow = {
+ let it = CanBecomeKeyWindow(
+ contentRect: .zero,
+ styleMask: .borderless,
+ backing: .buffered,
+ defer: false
+ )
+ it.isOpaque = false
+ it.backgroundColor = .clear
+ it.level = widgetLevel(2)
+ it.collectionBehavior = [.fullScreenAuxiliary, .transient, .canJoinAllSpaces]
+ it.contentView = NSHostingView(
+ rootView: NESDiffView(
+ store: store.scope(
+ state: \.panelState,
+ action: \.panel
+ ).scope(
+ state: \.nesSuggestionPanelState,
+ action: \.nesSuggestionPanel
+ )
+ )
+ )
+ it.canBecomeKeyChecker = { false }
+ it.setIsVisible(true)
+ it.hasShadow = true
+ return it
+ }()
+
+ @MainActor
+ lazy var nesNotificationWindow = {
+ let it = CanBecomeKeyWindow(
+ contentRect: .init(x: 0, y: 0, width: Style.panelWidth, height: Style.panelHeight),
+ styleMask: .borderless,
+ backing: .buffered,
+ defer: false
+ )
+ it.isOpaque = false
+ it.backgroundColor = .clear
+ it.level = widgetLevel(2)
+ it.collectionBehavior = [.fullScreenAuxiliary, .transient, .canJoinAllSpaces]
+ it.contentView = NSHostingView(
+ rootView: NESNotificationView(
+ store: store.scope(
+ state: \.panelState,
+ action: \.panel
+ ).scope(
+ state: \.nesSuggestionPanelState,
+ action: \.nesSuggestionPanel
+ )
+ )
+ )
+ it.canBecomeKeyChecker = { false }
+ it.setIsVisible(true)
+ return it
+ }()
@MainActor
lazy var codeReviewPanelWindow = {
@@ -1070,6 +1201,42 @@ public final class WidgetWindows {
)
).environment(cursorPositionTracker)
)
+ it.canBecomeKeyChecker = { false }
+ it.alphaValue = 0
+ it.setIsVisible(false)
+ return it
+ }()
+
+ @MainActor
+ lazy var agentConfigurationWidgetWindow = {
+ let it = CanBecomeKeyWindow(
+ contentRect: .init(
+ x: 0,
+ y: 0,
+ width: Style.panelWidth,
+ height: Style.panelHeight
+ ),
+ styleMask: .borderless,
+ backing: .buffered,
+ defer: true
+ )
+ it.isReleasedWhenClosed = false
+ it.isOpaque = false
+ it.backgroundColor = .clear
+ it.collectionBehavior = [.fullScreenAuxiliary, .transient, .canJoinAllSpaces]
+ it.hasShadow = false
+ it.level = widgetLevel(2)
+ it.contentView = NSHostingView(
+ rootView: AgentConfigurationWidgetView(
+ store: store.scope(
+ state: \.panelState,
+ action: \.panel
+ ).scope(
+ state: \.agentConfigurationWidgetState,
+ action: \.agentConfigurationWidget
+ )
+ ).environment(cursorPositionTracker)
+ )
it.canBecomeKeyChecker = { true }
it.alphaValue = 0
it.setIsVisible(false)
@@ -1134,7 +1301,11 @@ public final class WidgetWindows {
toastWindow.orderFrontRegardless()
sharedPanelWindow.orderFrontRegardless()
suggestionPanelWindow.orderFrontRegardless()
+ nesMenuWindow.orderFrontRegardless()
fixErrorPanelWindow.orderFrontRegardless()
+ nesDiffWindow.orderFrontRegardless()
+ nesNotificationWindow.orderFrontRegardless()
+ agentConfigurationWidgetWindow.orderFrontRegardless()
if chatPanelWindow.level.rawValue > NSWindow.Level.normal.rawValue {
chatPanelWindow.orderFrontRegardless()
}
diff --git a/Core/Tests/ChatServiceTests/ToolAutoApprovalParsingHelpersTests.swift b/Core/Tests/ChatServiceTests/ToolAutoApprovalParsingHelpersTests.swift
new file mode 100644
index 00000000..486b018f
--- /dev/null
+++ b/Core/Tests/ChatServiceTests/ToolAutoApprovalParsingHelpersTests.swift
@@ -0,0 +1,42 @@
+import XCTest
+@testable import ChatService
+
+class ToolAutoApprovalParsingHelpersTests: XCTestCase {
+ func testExtractSubCommandsWithTreeSitter() {
+ // Simple command
+ XCTAssertEqual(ToolAutoApprovalManager.extractSubCommandsWithTreeSitter("git status"), ["git status"])
+
+ // Chained commands
+ XCTAssertEqual(ToolAutoApprovalManager.extractSubCommandsWithTreeSitter("cd Core && swift test"), ["cd Core", "swift test"])
+ XCTAssertEqual(ToolAutoApprovalManager.extractSubCommandsWithTreeSitter("make build; make install"), ["make build", "make install"])
+ XCTAssertEqual(ToolAutoApprovalManager.extractSubCommandsWithTreeSitter("make build || echo 'fail'"), ["make build", "echo 'fail'"])
+
+ // Pipes
+ XCTAssertEqual(ToolAutoApprovalManager.extractSubCommandsWithTreeSitter("ls -la | grep swift"), ["ls -la", "grep swift"])
+ XCTAssertEqual(ToolAutoApprovalManager.extractSubCommandsWithTreeSitter("ls &> out.txt"), ["ls &> out.txt"])
+
+ // Complex with quotes (content inside quotes shouldn't be split)
+ XCTAssertEqual(ToolAutoApprovalManager.extractSubCommandsWithTreeSitter("echo 'hello && world'"), ["echo 'hello && world'"])
+ XCTAssertEqual(ToolAutoApprovalManager.extractSubCommandsWithTreeSitter("echo $(date +%Y) && ls"), ["echo $", "date +%Y", "ls"])
+
+ XCTAssertEqual(ToolAutoApprovalManager.extractSubCommandsWithTreeSitter("git commit -m \"fix: update && clean\""), ["git commit -m \"fix: update && clean\""])
+
+ // Nested / Subshells (might depend on how detailed the query is)
+ // (command) query usually picks up the command nodes.
+ // For `(cd Core; ls)`, the inner commands are commands too.
+ XCTAssertEqual(Set(ToolAutoApprovalManager.extractSubCommandsWithTreeSitter("(cd Core; ls)")), Set(["cd Core", "ls"]))
+
+ // Empty or whitespace
+ XCTAssertEqual(ToolAutoApprovalManager.extractSubCommandsWithTreeSitter(" "), [])
+ }
+
+ func testExtractTerminalCommandNames() {
+ XCTAssertEqual(ToolAutoApprovalManager.extractTerminalCommandNames(from: "git status"), ["git"])
+ XCTAssertEqual(ToolAutoApprovalManager.extractTerminalCommandNames(from: "run_tests.sh --verbose"), ["run_tests.sh"])
+ XCTAssertEqual(ToolAutoApprovalManager.extractTerminalCommandNames(from: "sudo apt-get install"), ["apt-get"])
+ XCTAssertEqual(ToolAutoApprovalManager.extractTerminalCommandNames(from: "env VAR=1 command run"), ["command"])
+ XCTAssertEqual(ToolAutoApprovalManager.extractTerminalCommandNames(from: "cd Core && swift test"), ["cd", "swift"])
+ XCTAssertEqual(ToolAutoApprovalManager.extractTerminalCommandNames(from: "ls | grep match"), ["ls", "grep"])
+ XCTAssertEqual(ToolAutoApprovalManager.extractTerminalCommandNames(from: "ls &> out.txt"), ["ls"])
+ }
+}
diff --git a/Core/Tests/KeyBindingManagerTests/TabToAcceptSuggestionTests.swift b/Core/Tests/KeyBindingManagerTests/TabToAcceptSuggestionTests.swift
index 70469700..966f047f 100644
--- a/Core/Tests/KeyBindingManagerTests/TabToAcceptSuggestionTests.swift
+++ b/Core/Tests/KeyBindingManagerTests/TabToAcceptSuggestionTests.swift
@@ -1,10 +1,12 @@
import Foundation
import XCTest
+import SuggestionBasic
@testable import Workspace
@testable import KeyBindingManager
class TabToAcceptSuggestionTests: XCTestCase {
+
@WorkspaceActor
func test_should_accept() {
let fileURL = URL(string: "file:///test")!
@@ -20,7 +22,7 @@ class TabToAcceptSuggestionTests: XCTestCase {
event: CGEvent(keyboardEventSource: nil, virtualKey: 48, keyDown: true)!,
workspacePool: workspacePool,
xcodeInspector: xcodeInspector
- ), (true, nil)
+ ), (true, nil, .codeCompletion)
)
}
@@ -39,7 +41,7 @@ class TabToAcceptSuggestionTests: XCTestCase {
event: CGEvent(keyboardEventSource: nil, virtualKey: 48, keyDown: true)!,
workspacePool: workspacePool,
xcodeInspector: xcodeInspector
- ), (false, "No suggestion")
+ ), (false, "No suggestion", nil)
)
}
@@ -57,7 +59,7 @@ class TabToAcceptSuggestionTests: XCTestCase {
event: CGEvent(keyboardEventSource: nil, virtualKey: 48, keyDown: true)!,
workspacePool: workspacePool,
xcodeInspector: xcodeInspector
- ), (false, "No filespace")
+ ), (false, "No filespace", nil)
)
}
@@ -76,7 +78,7 @@ class TabToAcceptSuggestionTests: XCTestCase {
event: CGEvent(keyboardEventSource: nil, virtualKey: 48, keyDown: true)!,
workspacePool: workspacePool,
xcodeInspector: xcodeInspector
- ), (false, "No focused editor")
+ ), (false, "No focused editor", nil)
)
}
@@ -95,7 +97,7 @@ class TabToAcceptSuggestionTests: XCTestCase {
event: createEvent(48),
workspacePool: workspacePool,
xcodeInspector: xcodeInspector
- ), (false, "No active Xcode")
+ ), (false, "No active Xcode", nil)
)
}
@@ -114,7 +116,7 @@ class TabToAcceptSuggestionTests: XCTestCase {
event: createEvent(48),
workspacePool: workspacePool,
xcodeInspector: xcodeInspector
- ), (false, "No active document")
+ ), (false, "No active document", nil)
)
}
@@ -133,7 +135,7 @@ class TabToAcceptSuggestionTests: XCTestCase {
event: createEvent(48, flags: .maskShift),
workspacePool: workspacePool,
xcodeInspector: xcodeInspector
- ), (false, nil)
+ ), (false, nil, nil)
)
}
@@ -152,7 +154,7 @@ class TabToAcceptSuggestionTests: XCTestCase {
event: createEvent(48, flags: .maskCommand),
workspacePool: workspacePool,
xcodeInspector: xcodeInspector
- ), (false, nil)
+ ), (false, nil, nil)
)
}
@@ -171,7 +173,7 @@ class TabToAcceptSuggestionTests: XCTestCase {
event: createEvent(48, flags: .maskControl),
workspacePool: workspacePool,
xcodeInspector: xcodeInspector
- ), (false, nil)
+ ), (false, nil, nil)
)
}
@@ -190,33 +192,14 @@ class TabToAcceptSuggestionTests: XCTestCase {
event: createEvent(48, flags: .maskHelp),
workspacePool: workspacePool,
xcodeInspector: xcodeInspector
- ), (false, nil)
- )
- }
-
- @WorkspaceActor
- func test_should_not_accept_without_tab() {
- let fileURL = URL(string: "file:///test")!
- let workspacePool = FakeWorkspacePool()
- workspacePool.setTestFile(fileURL: fileURL)
- let xcodeInspector = FakeThreadSafeAccessToXcodeInspector(
- activeDocumentURL: fileURL,
- hasActiveXcode: true,
- hasFocusedEditor: true
- )
- assertEqual(
- TabToAcceptSuggestion.shouldAcceptSuggestion(
- event: createEvent(50),
- workspacePool: workspacePool,
- xcodeInspector: xcodeInspector
- ), (false, nil)
+ ), (false, nil, nil)
)
}
}
private func assertEqual(
- _ result: (Bool, String?),
- _ expected: (Bool, String?)
+ _ result: (Bool, String?, CodeSuggestionType?),
+ _ expected: (Bool, String?, CodeSuggestionType?),
) {
if result != expected {
XCTFail("Expected \(expected), got \(result)")
@@ -242,7 +225,7 @@ private class FakeWorkspacePool: WorkspacePool {
@WorkspaceActor
func setTestFile(fileURL: URL, skipSuggestion: Bool = false) {
self.fileURL = fileURL
- self.filespace = Filespace(fileURL: fileURL, onSave: {_ in }, onClose: {_ in })
+ self.filespace = Filespace(fileURL: fileURL, content: "", onSave: {_ in }, onClose: {_ in })
if skipSuggestion { return }
guard let filespace = self.filespace else { return }
filespace.setSuggestions([.init(id: "id", text: "test", position: .zero, range: .zero)])
diff --git a/Core/Tests/ServiceTests/Environment.swift b/Core/Tests/ServiceTests/Environment.swift
index 191bf6aa..a018b294 100644
--- a/Core/Tests/ServiceTests/Environment.swift
+++ b/Core/Tests/ServiceTests/Environment.swift
@@ -6,6 +6,7 @@ import SuggestionBasic
import Workspace
import XCTest
import XPCShared
+import LanguageServerProtocol
@testable import Service
@@ -26,7 +27,7 @@ class MockSuggestionService: GitHubCopilotSuggestionServiceType {
fatalError()
}
- func notifyChangeTextDocument(fileURL: URL, content: String, version: Int) async throws {
+ func notifyChangeTextDocument(fileURL: URL, content: String, version: Int, contentChanges: [LanguageServerProtocol.TextDocumentContentChangeEvent]?) async throws {
fatalError()
}
@@ -59,13 +60,29 @@ class MockSuggestionService: GitHubCopilotSuggestionServiceType {
completions
}
+ func getCopilotInlineEdit(
+ fileURL: URL,
+ content: String,
+ cursorPosition: SuggestionBasic.CursorPosition
+ ) async throws -> [SuggestionBasic.CodeSuggestion] {
+ completions
+ }
+
func notifyShown(_ completion: SuggestionBasic.CodeSuggestion) async {
shown = completion.id
}
+
+ func notifyCopilotInlineEditShown(_ completion: SuggestionBasic.CodeSuggestion) async {
+ shown = completion.id
+ }
func notifyAccepted(_ completion: CodeSuggestion, acceptedLength: Int? = nil) async {
accepted = completion.id
}
+
+ func notifyCopilotInlineEditAccepted(_ completion: CodeSuggestion) async {
+ accepted = completion.id
+ }
func notifyRejected(_ completions: [CodeSuggestion]) async {
rejected = completions.map(\.id)
diff --git a/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift b/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift
index 941a6c84..83990303 100644
--- a/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift
+++ b/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift
@@ -21,6 +21,7 @@ class FilespaceSuggestionInvalidationTests: XCTestCase {
let pool = WorkspacePool()
let filespace = Filespace(
fileURL: URL(fileURLWithPath: "file/path/to.swift"),
+ content: "",
onSave: { _ in },
onClose: { _ in }
)
diff --git a/Core/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift b/Core/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift
index dfdb4b3e..653492fc 100644
--- a/Core/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift
+++ b/Core/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift
@@ -856,6 +856,57 @@ final class AcceptSuggestionTests: XCTestCase {
""")
}
+
+ func test_accept_multi_lines_suggestion_with_overlay() async throws {
+ let content = """
+ struct Cat {
+ var name: String
+ var age: String
+ }
+ """
+ let text = """
+ newName: String
+ var newAge
+ """
+ let suggestion = CodeSuggestion(
+ id: "",
+ text: text,
+ position: .init(line: 1, character: 12),
+ range: .init(
+ start: .init(line: 1, character: 8),
+ end: .init(line: 2, character: 11)
+ )
+ )
+
+ var extraInfo = SuggestionInjector.ExtraInfo()
+ var lines = content.breakIntoEditorStyleLines()
+ var cursor = CursorPosition(line: 1, character: 12)
+ SuggestionInjector().acceptSuggestion(
+ intoContentWithoutSuggestion: &lines,
+ cursorPosition: &cursor,
+ completion: suggestion,
+ extraInfo: &extraInfo,
+ isNES: true
+ )
+ XCTAssertTrue(extraInfo.didChangeContent)
+ XCTAssertTrue(extraInfo.didChangeCursorPosition)
+ XCTAssertNil(extraInfo.suggestionRange)
+ XCTAssertEqual(
+ lines,
+ content.breakIntoEditorStyleLines().applying(extraInfo.modifications)
+ )
+ XCTAssertEqual(cursor, .init(line: 2, character: 22))
+ XCTAssertEqual(
+ lines.joined(separator: ""),
+ """
+ struct Cat {
+ var newName: String
+ var newAge: String
+ }
+
+ """
+ )
+ }
}
extension String {
diff --git a/Core/Tests/SuggestionWidgetTests/NES/NESDiffBuilderTests.swift b/Core/Tests/SuggestionWidgetTests/NES/NESDiffBuilderTests.swift
new file mode 100644
index 00000000..fb52105b
--- /dev/null
+++ b/Core/Tests/SuggestionWidgetTests/NES/NESDiffBuilderTests.swift
@@ -0,0 +1,55 @@
+import XCTest
+
+@testable import SuggestionWidget
+
+final class NESDiffBuilderTests: XCTestCase {
+ func testInlineSegmentsIdentifiesChangesWithinLine() {
+ let oldLine = " let foo = 1"
+ let newLine = " let bar = 2"
+
+ let segments = DiffBuilder.inlineSegments(oldLine: oldLine, newLine: newLine)
+
+ XCTAssertEqual(segments.count, 6)
+ XCTAssertEqual(
+ segments.map(\.change),
+ [.unchanged, .removed, .added, .unchanged, .removed, .added]
+ )
+ XCTAssertEqual(
+ segments.map(\.text),
+ [" let ", "foo ", "bar ", "= ", "1", "2"]
+ )
+ }
+
+ func testInlineSegmentsWhenOldLineIsEmptyTreatsNewContentAsAdded() {
+ let segments = DiffBuilder.inlineSegments(oldLine: "", newLine: "value")
+
+ XCTAssertEqual(segments.count, 1)
+ XCTAssertEqual(segments.first?.change, .added)
+ XCTAssertEqual(segments.first?.text, "value")
+ }
+
+ func testLineSegmentsReturnsDiffAcrossLineBoundaries() {
+ let oldContent = [
+ "line1",
+ "line2",
+ "line3"
+ ].joined(separator: "\n")
+ let newContent = [
+ "line1",
+ "line3"
+ ].joined(separator: "\n")
+
+ let segments = DiffBuilder.lineSegments(oldContent: oldContent, newContent: newContent)
+
+ XCTAssertEqual(segments.count, 3)
+ XCTAssertEqual(
+ segments.map(\.change),
+ [.unchanged, .removed, .unchanged]
+ )
+ XCTAssertEqual(
+ segments.map(\.text),
+ ["line1", "line2", "line3"]
+ )
+ }
+}
+
diff --git a/Docs/Images/chat_agent.gif b/Docs/Images/chat_agent.gif
new file mode 100644
index 00000000..a6a684d1
Binary files /dev/null and b/Docs/Images/chat_agent.gif differ
diff --git a/Docs/Images/chat_dark.gif b/Docs/Images/chat_dark.gif
deleted file mode 100644
index abd5cc20..00000000
Binary files a/Docs/Images/chat_dark.gif and /dev/null differ
diff --git a/EditorExtension/AcceptNESSuggestionCommand.swift b/EditorExtension/AcceptNESSuggestionCommand.swift
new file mode 100644
index 00000000..6c015030
--- /dev/null
+++ b/EditorExtension/AcceptNESSuggestionCommand.swift
@@ -0,0 +1,32 @@
+import Client
+import SuggestionBasic
+import Foundation
+import XcodeKit
+import XPCShared
+
+class AcceptNESSuggestionCommand: NSObject, XCSourceEditorCommand, CommandType {
+ var name: String { "Accept Next Edit Suggestion" }
+
+ func perform(
+ with invocation: XCSourceEditorCommandInvocation,
+ completionHandler: @escaping (Error?) -> Void
+ ) {
+ Task {
+ do {
+ try await (Task(timeout: 7) {
+ let service = try getService()
+ if let content = try await service.getNESSuggestionAcceptedCode(
+ editorContent: .init(invocation)
+ ) {
+ invocation.accept(content)
+ }
+ completionHandler(nil)
+ }.value)
+ } catch is CancellationError {
+ completionHandler(nil)
+ } catch {
+ completionHandler(error)
+ }
+ }
+ }
+}
diff --git a/EditorExtension/RejectNESSuggestionCommand.swift b/EditorExtension/RejectNESSuggestionCommand.swift
new file mode 100644
index 00000000..43183779
--- /dev/null
+++ b/EditorExtension/RejectNESSuggestionCommand.swift
@@ -0,0 +1,20 @@
+import Client
+import Foundation
+import SuggestionBasic
+import XcodeKit
+
+class RejectNESSuggestionCommand: NSObject, XCSourceEditorCommand, CommandType {
+ var name: String { "Decline Next Edit Suggestion" }
+
+ func perform(
+ with invocation: XCSourceEditorCommandInvocation,
+ completionHandler: @escaping (Error?) -> Void
+ ) {
+ completionHandler(nil)
+ Task {
+ let service = try getService()
+ _ = try await service.getNESSuggestionRejectedCode(editorContent: .init(invocation))
+ }
+ }
+}
+
diff --git a/EditorExtension/SourceEditorExtension.swift b/EditorExtension/SourceEditorExtension.swift
index a9d252f9..a0ca6579 100644
--- a/EditorExtension/SourceEditorExtension.swift
+++ b/EditorExtension/SourceEditorExtension.swift
@@ -12,7 +12,9 @@ class SourceEditorExtension: NSObject, XCSourceEditorExtension {
var builtin: [[XCSourceEditorCommandDefinitionKey: Any]] {
[
AcceptSuggestionCommand(),
+ AcceptNESSuggestionCommand(),
RejectSuggestionCommand(),
+ RejectNESSuggestionCommand(),
GetSuggestionsCommand(),
NextSuggestionCommand(),
PreviousSuggestionCommand(),
diff --git a/ExtensionService/AppDelegate+Menu.swift b/ExtensionService/AppDelegate+Menu.swift
index 4dfc0da1..bc99ed6a 100644
--- a/ExtensionService/AppDelegate+Menu.swift
+++ b/ExtensionService/AppDelegate+Menu.swift
@@ -88,6 +88,12 @@ extension AppDelegate {
action: nil,
keyEquivalent: ""
)
+
+ toggleNES = NSMenuItem(
+ title: "Enable/Disable Next Edit Suggestions (NES)",
+ action: #selector(toggleNESEnabled),
+ keyEquivalent: ""
+ )
// Auth menu item with custom view
accountItem = NSMenuItem()
@@ -105,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
@@ -163,6 +159,7 @@ extension AppDelegate {
statusBarMenu.addItem(openChat)
statusBarMenu.addItem(toggleCompletions)
statusBarMenu.addItem(toggleIgnoreLanguage)
+ statusBarMenu.addItem(toggleNES)
statusBarMenu.addItem(.separator())
statusBarMenu.addItem(openCopilotForXcodeItem)
statusBarMenu.addItem(openDocs)
@@ -204,6 +201,10 @@ extension AppDelegate: NSMenuDelegate {
toggleIgnoreLanguage.action = nil
}
}
+
+ if toggleNES != nil {
+ toggleNES.title = "\(UserDefaults.shared.value(for: \.realtimeNESToggle) ? "Disable" : "Enable") Next Edit Suggestions (NES)"
+ }
Task {
await forceAuthStatusCheck()
@@ -322,6 +323,19 @@ private extension AppDelegate {
}
}
+ @objc func toggleNESEnabled() {
+ Task {
+ let initialSetting = UserDefaults.shared.value(for: \.realtimeNESToggle)
+ do {
+ let service = getXPCExtensionService()
+ try await service.toggleRealtimeNES()
+ } catch {
+ Logger.service.error("Failed to toggle NES enabled via XPC: \(error)")
+ UserDefaults.shared.set(!initialSetting, for: \.realtimeNESToggle)
+ }
+ }
+ }
+
@objc func toggleIgnoreLanguageEnabled() {
guard let lang = DisabledLanguageList.shared.activeDocumentLanguage else { return }
diff --git a/ExtensionService/AppDelegate.swift b/ExtensionService/AppDelegate.swift
index a0dd47c6..fcbad9d6 100644
--- a/ExtensionService/AppDelegate.swift
+++ b/ExtensionService/AppDelegate.swift
@@ -42,6 +42,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
var quotaItem: NSMenuItem!
var toggleCompletions: NSMenuItem!
var toggleIgnoreLanguage: NSMenuItem!
+ var toggleNES: NSMenuItem!
var openChat: NSMenuItem!
var signOutItem: NSMenuItem!
var xpcController: XPCController?
@@ -67,6 +68,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
Logger.service.info("XPC Service started.")
NSApp.setActivationPolicy(.accessory)
buildStatusBarMenu()
+ _ = FeatureFlagNotifierImpl.shared
+ observeFeatureFlags()
watchServiceStatus()
watchAXStatus()
watchAuthStatus()
@@ -234,6 +237,16 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
}
}
}
+
+
+ func observeFeatureFlags() {
+ Task { @MainActor in
+ FeatureFlagNotifierImpl.shared.featureFlagsDidChange
+ .sink(receiveValue: { [weak self] featureFlags in
+ self?.toggleNES.isHidden = !featureFlags.editorPreviewFeatures
+ })
+ }
+ }
func watchAuthStatus() {
let notifications = DistributedNotificationCenter.default().notifications(named: .authStatusDidChange)
@@ -269,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)")
@@ -286,6 +298,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
self.quotaItem.isHidden = true
self.toggleCompletions.isHidden = true
self.toggleIgnoreLanguage.isHidden = true
+ self.toggleNES.isHidden = true
self.signOutItem.isHidden = true
}
@@ -319,33 +332,16 @@ 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
}
self.toggleCompletions.isHidden = false
self.toggleIgnoreLanguage.isHidden = false
+ self.toggleNES.isHidden = false
self.signOutItem.isHidden = false
}
@@ -398,6 +394,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
self.quotaItem.isHidden = true
self.toggleCompletions.isHidden = true
self.toggleIgnoreLanguage.isHidden = true
+ self.toggleNES.isHidden = true
self.signOutItem.isHidden = false
}
@@ -411,6 +408,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
self.quotaItem.isHidden = true
self.toggleCompletions.isHidden = false
self.toggleIgnoreLanguage.isHidden = false
+ self.toggleNES.isHidden = false
self.signOutItem.isHidden = false
}
@@ -518,6 +516,7 @@ enum CLSMessageType {
case chatLimitReached
case completionLimitReached
case byokLimitedReached
+ case monthlyAICreditsLimitReached
case other
var summary: String {
@@ -528,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"
}
@@ -539,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
@@ -557,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/Agent.imageset/Agent.svg b/ExtensionService/Assets.xcassets/Agent.imageset/Agent.svg
new file mode 100644
index 00000000..0d699645
--- /dev/null
+++ b/ExtensionService/Assets.xcassets/Agent.imageset/Agent.svg
@@ -0,0 +1,10 @@
+
diff --git a/ExtensionService/Assets.xcassets/CopilotLogo.imageset/Contents.json b/ExtensionService/Assets.xcassets/Agent.imageset/Contents.json
similarity index 54%
rename from ExtensionService/Assets.xcassets/CopilotLogo.imageset/Contents.json
rename to ExtensionService/Assets.xcassets/Agent.imageset/Contents.json
index 2e35661e..7154a326 100644
--- a/ExtensionService/Assets.xcassets/CopilotLogo.imageset/Contents.json
+++ b/ExtensionService/Assets.xcassets/Agent.imageset/Contents.json
@@ -1,7 +1,7 @@
{
"images" : [
{
- "filename" : "copilot.svg",
+ "filename" : "Agent.svg",
"idiom" : "universal"
}
],
@@ -10,6 +10,7 @@
"version" : 1
},
"properties" : {
- "preserves-vector-representation" : true
+ "preserves-vector-representation" : true,
+ "template-rendering-intent" : "template"
}
}
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/Colors/AgentToolStatusDividerColor.colorset/Contents.json b/ExtensionService/Assets.xcassets/Colors/AgentToolStatusDividerColor.colorset/Contents.json
new file mode 100644
index 00000000..8ea26959
--- /dev/null
+++ b/ExtensionService/Assets.xcassets/Colors/AgentToolStatusDividerColor.colorset/Contents.json
@@ -0,0 +1,33 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0xF0",
+ "green" : "0xEC",
+ "red" : "0xEB"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "platform" : "universal",
+ "reference" : "secondaryLabelColor"
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ExtensionService/Assets.xcassets/Colors/AgentToolStatusOutlineColor.colorset/Contents.json b/ExtensionService/Assets.xcassets/Colors/AgentToolStatusOutlineColor.colorset/Contents.json
new file mode 100644
index 00000000..58029746
--- /dev/null
+++ b/ExtensionService/Assets.xcassets/Colors/AgentToolStatusOutlineColor.colorset/Contents.json
@@ -0,0 +1,33 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0xE5",
+ "green" : "0xE1",
+ "red" : "0xDF"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "platform" : "universal",
+ "reference" : "secondaryLabelColor"
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ExtensionService/Assets.xcassets/Colors/Contents.json b/ExtensionService/Assets.xcassets/Colors/Contents.json
new file mode 100644
index 00000000..73c00596
--- /dev/null
+++ b/ExtensionService/Assets.xcassets/Colors/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ExtensionService/Assets.xcassets/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/EditSparkle.imageset/Contents.json b/ExtensionService/Assets.xcassets/EditSparkle.imageset/Contents.json
new file mode 100644
index 00000000..c66c7fae
--- /dev/null
+++ b/ExtensionService/Assets.xcassets/EditSparkle.imageset/Contents.json
@@ -0,0 +1,16 @@
+{
+ "images" : [
+ {
+ "filename" : "edit-sparkle.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "preserves-vector-representation" : true,
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/ExtensionService/Assets.xcassets/EditSparkle.imageset/edit-sparkle.svg b/ExtensionService/Assets.xcassets/EditSparkle.imageset/edit-sparkle.svg
new file mode 100644
index 00000000..9cff22ff
--- /dev/null
+++ b/ExtensionService/Assets.xcassets/EditSparkle.imageset/edit-sparkle.svg
@@ -0,0 +1,12 @@
+
diff --git a/ExtensionService/Assets.xcassets/IconStrokeColor.colorset/Contents.json b/ExtensionService/Assets.xcassets/IconStrokeColor.colorset/Contents.json
new file mode 100644
index 00000000..dbc3a4e3
--- /dev/null
+++ b/ExtensionService/Assets.xcassets/IconStrokeColor.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "display-p3",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0x7E",
+ "green" : "0x70",
+ "red" : "0x6C"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "display-p3",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0xD6",
+ "green" : "0xD0",
+ "red" : "0xCE"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ExtensionService/Assets.xcassets/LightBlue.colorset/Contents.json b/ExtensionService/Assets.xcassets/LightBlue.colorset/Contents.json
index 3c56d13b..552c7769 100644
--- a/ExtensionService/Assets.xcassets/LightBlue.colorset/Contents.json
+++ b/ExtensionService/Assets.xcassets/LightBlue.colorset/Contents.json
@@ -5,9 +5,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
- "blue" : "255",
- "green" : "226",
- "red" : "212"
+ "blue" : "0xFF",
+ "green" : "0xE2",
+ "red" : "0xD4"
}
},
"idiom" : "universal"
diff --git a/ExtensionService/Assets.xcassets/LightBluePrimary.colorset/Contents.json b/ExtensionService/Assets.xcassets/LightBluePrimary.colorset/Contents.json
new file mode 100644
index 00000000..1c8b1e91
--- /dev/null
+++ b/ExtensionService/Assets.xcassets/LightBluePrimary.colorset/Contents.json
@@ -0,0 +1,20 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0xF0",
+ "green" : "0x74",
+ "red" : "0x35"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ExtensionService/Assets.xcassets/NESShadowColor.colorset/Contents.json b/ExtensionService/Assets.xcassets/NESShadowColor.colorset/Contents.json
new file mode 100644
index 00000000..f606b54c
--- /dev/null
+++ b/ExtensionService/Assets.xcassets/NESShadowColor.colorset/Contents.json
@@ -0,0 +1,20 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "0.180",
+ "blue" : "0x26",
+ "green" : "0x1F",
+ "red" : "0x1B"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ExtensionService/Assets.xcassets/SubagentTurnBackground.colorset/Contents.json b/ExtensionService/Assets.xcassets/SubagentTurnBackground.colorset/Contents.json
new file mode 100644
index 00000000..3bd5adce
--- /dev/null
+++ b/ExtensionService/Assets.xcassets/SubagentTurnBackground.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.940",
+ "green" : "0.930",
+ "red" : "0.920"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.260",
+ "green" : "0.230",
+ "red" : "0.220"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ExtensionService/Assets.xcassets/WarningYellow.colorset/Contents.json b/ExtensionService/Assets.xcassets/WarningYellow.colorset/Contents.json
new file mode 100644
index 00000000..fb5231df
--- /dev/null
+++ b/ExtensionService/Assets.xcassets/WarningYellow.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "display-p3",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0x04",
+ "green" : "0x7D",
+ "red" : "0xC2"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "display-p3",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0x5C",
+ "green" : "0xC5",
+ "red" : "0xF2"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ExtensionService/XPCController.swift b/ExtensionService/XPCController.swift
index 02656f85..d47ea0ef 100644
--- a/ExtensionService/XPCController.swift
+++ b/ExtensionService/XPCController.swift
@@ -59,7 +59,7 @@ final class XPCController: XPCServiceDelegate {
// No log, but you should run CommunicationBridge, too.
#else
if consecutiveFailures == 5 {
- if #available(macOS 13.0, *) {
+ await MainActor.run {
showBackgroundPermissionAlert()
}
}
diff --git a/README.md b/README.md
index c6b9553a..74db8114 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +1,20 @@
#
GitHub Copilot for Xcode
-[GitHub Copilot](https://github.com/features/copilot) is an AI pair programmer
-tool that helps you write code faster and smarter. Copilot for Xcode is an Xcode extension that provides inline coding suggestions as you type and a chat assistant to answer your coding questions.
+[GitHub Copilot](https://github.com/features/copilot) for Xcode is the leading AI coding assistant for Swift, Objective-C and iOS/macOS development. It delivers intelligent Completions, Chat, and Code Reviewβplus advanced features like Agent Mode, Next Edit Suggestions, MCP Registry, and Copilot Vision to make Xcode development faster and smarter.
+
+> [!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
GitHub Copilot Chat provides suggestions to your specific coding tasks via chat.
-
+
## Agent Mode
@@ -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
@@ -146,11 +154,11 @@ Copilot for Xcode.
Weβd love to get your help in making GitHub Copilot better! If you have
feedback or encounter any problems, please reach out on our [Feedback
-forum](https://github.com/orgs/community/discussions/categories/copilot).
+forum](https://github.com/github/CopilotForXcode/discussions).
## Acknowledgements
Thank you to @intitni for creating the original project that this is based on.
Attributions can be found under About when running the app or in
-[Credits.rtf](./Copilot%20for%20Xcode/Credits.rtf).
\ No newline at end of file
+[Credits.rtf](./Copilot%20for%20Xcode/Credits.rtf).
diff --git a/ReleaseNotes.md b/ReleaseNotes.md
index e9207fec..3c922d4a 100644
--- a/ReleaseNotes.md
+++ b/ReleaseNotes.md
@@ -1,23 +1,13 @@
-### GitHub Copilot for Xcode 0.44.0
+### GitHub Copilot for Xcode 0.50.0
**π Highlights**
-* Added support for new models in Chat: Grok Code Fast 1, Claude Sonnet 4.5, Claude Opus 4, Claude Opus 4.1 and GPT-5 mini.
-* Added support for restoring to a saved checkpoint snapshot.
-* Added support for tool selection in agent mode.
-* Added the ability to adjust the chat panel font size.
-* Added the ability to edit a previous chat message and resend it.
-* Introduced a new setting to disable the Copilot βFix Errorβ button.
-* Added support for custom instructions in the Code Review feature.
+- **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.
-**πͺ Improvements**
+- **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.
-* Switched authentication to a new OAuth app "GitHub Copilot IDE Plugin".
-* Updated the chat layout to a messenger-style conversation view (user messages on the right, responses on the left).
-* Now shows a clearer, more user-friendly message when Copilot finishes responding.
-* Added support for skipping a tool call without ending the conversation.
+**πͺ Changes**
-**π οΈ Bug Fixes**
+- 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.
-* Fixed a command injection vulnerability when opening referenced chat files.
-* Resolved display issues in the chat view on macOS 26.
+ We **strongly recommend** upgrading to this version as soon as possible.
diff --git a/SUPPORT.md b/SUPPORT.md
index 33762051..fe426a0a 100644
--- a/SUPPORT.md
+++ b/SUPPORT.md
@@ -4,7 +4,7 @@
Weβd love to get your help in making GitHub Copilot better! If you have
feedback or encounter any problems, please reach out on our [Feedback
-forum](https://github.com/orgs/community/discussions/categories/copilot).
+forum](https://github.com/github/CopilotForXcode/discussions).
GitHub Copilot for Xcode is under active development and maintained by GitHub
staff. We will do our best to respond to support, feature requests, and
diff --git a/Server/package-lock.json b/Server/package-lock.json
index 0ea8ecd0..5a375b85 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.381.0",
- "@github/copilot-language-server-darwin-arm64": "1.381.0",
- "@github/copilot-language-server-darwin-x64": "1.381.0",
+ "@github/copilot-language-server": "1.523.3",
+ "@github/copilot-language-server-darwin-arm64": "1.523.3",
+ "@github/copilot-language-server-darwin-x64": "1.523.3",
"@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.381.0",
- "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.381.0.tgz",
- "integrity": "sha512-SIqpmcnm//jdVMyAn6xL4yfIIyJ5/Gzuo8gil0LjAsTNSl02jWx9scyAQFHgn8DIb42elgpJRPNDBwbL/1+Gmw==",
+ "version": "1.523.3",
+ "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.523.3.tgz",
+ "integrity": "sha512-beEj0Mvw6hZSqYyj+7vvtkZ8s9G8IHI4D95HhCHYAk68LGhMBm2lri0WQ9if7NQPZa0fhEkNhlF6SEFTgqiPFw==",
"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.381.0",
- "@github/copilot-language-server-darwin-x64": "1.381.0",
- "@github/copilot-language-server-linux-arm64": "1.381.0",
- "@github/copilot-language-server-linux-x64": "1.381.0",
- "@github/copilot-language-server-win32-x64": "1.381.0"
+ "@github/copilot-language-server-darwin-arm64": "1.523.3",
+ "@github/copilot-language-server-darwin-x64": "1.523.3",
+ "@github/copilot-language-server-linux-arm64": "1.523.3",
+ "@github/copilot-language-server-linux-x64": "1.523.3",
+ "@github/copilot-language-server-win32-arm64": "1.523.3",
+ "@github/copilot-language-server-win32-x64": "1.523.3"
}
},
"node_modules/@github/copilot-language-server-darwin-arm64": {
- "version": "1.381.0",
- "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-arm64/-/copilot-language-server-darwin-arm64-1.381.0.tgz",
- "integrity": "sha512-mmwUlTs8uWWpoj/cZVzPi6tvujB6Oo7K7uW61xcBlFQdiTu6h8lwt0I3YzdxyIPMtdYKYATloZ+lXzuOMjWtXw==",
+ "version": "1.523.3",
+ "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-arm64/-/copilot-language-server-darwin-arm64-1.523.3.tgz",
+ "integrity": "sha512-RllQkKiU4Tw3ag9sv1KNE3s97ZYjvbPjnMr0D7RCINq875/rHfdCEY+H1q42YgafZF+gGilHIW12HpA2iW6NUQ==",
"cpu": [
"arm64"
],
@@ -69,9 +70,9 @@
]
},
"node_modules/@github/copilot-language-server-darwin-x64": {
- "version": "1.381.0",
- "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-x64/-/copilot-language-server-darwin-x64-1.381.0.tgz",
- "integrity": "sha512-V5vV7aPzQXMGlRMmVCeZt0JVrz6pQlTw9Y8zjua2dB1oMglSnF/bC8jULiXxBJbf+nryuU1UZdZkyYbOVCn1hg==",
+ "version": "1.523.3",
+ "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-x64/-/copilot-language-server-darwin-x64-1.523.3.tgz",
+ "integrity": "sha512-c1L9Auej3jlIR2CilLsXchIEFleS158v5HtrRdyzXytPwQsrG+Wvf7e7vC5L8jTTLhAitCm9o/VqeaUAs2FGMQ==",
"cpu": [
"x64"
],
@@ -81,9 +82,9 @@
]
},
"node_modules/@github/copilot-language-server-linux-arm64": {
- "version": "1.381.0",
- "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-arm64/-/copilot-language-server-linux-arm64-1.381.0.tgz",
- "integrity": "sha512-g1fR1LdgsG3rX6gwhpqgAlBUD5By6Dh3lu0TrxkPCPimMhzVmiq2iku1ijvEm6gZhyFLExoPPoR5fNZ72O2ZLA==",
+ "version": "1.523.3",
+ "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-arm64/-/copilot-language-server-linux-arm64-1.523.3.tgz",
+ "integrity": "sha512-JSWwX8CJ1mGBTfcLS+vB4q7DUCVDqi2AzFPYGGBHXYnRGa77X75gDdai1/kIO5OO12S2FqyjIPr7j9wY4Zas5w==",
"cpu": [
"arm64"
],
@@ -94,9 +95,9 @@
]
},
"node_modules/@github/copilot-language-server-linux-x64": {
- "version": "1.381.0",
- "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-x64/-/copilot-language-server-linux-x64-1.381.0.tgz",
- "integrity": "sha512-O8kKl2Ir7RrEwRV4aGQJiAMV0VEbKm+eUKC7uyeCYW9cse1/1uNQDiGiaIM5cMdt9I39TGANOedddTrkdnkXLw==",
+ "version": "1.523.3",
+ "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-x64/-/copilot-language-server-linux-x64-1.523.3.tgz",
+ "integrity": "sha512-/NVKbSkdsTt11i2CYWcgBZAVTYHk0bCrb9tmwbBDRGPeZd+qyiWWv7roVOh6Ocu9ofX/BDCTUW8nZ2hOt3MKvQ==",
"cpu": [
"x64"
],
@@ -106,10 +107,23 @@
"linux"
]
},
+ "node_modules/@github/copilot-language-server-win32-arm64": {
+ "version": "1.523.3",
+ "resolved": "https://registry.npmjs.org/@github/copilot-language-server-win32-arm64/-/copilot-language-server-win32-arm64-1.523.3.tgz",
+ "integrity": "sha512-o5GC4Gutkkw1Fz00h7Q2vB6/u93vjfYOjzvYJ1XaXGUTczUeFdgiwn6b7+snp4Gpy5ad20Sqk03Zd/KeOVz8KQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
"node_modules/@github/copilot-language-server-win32-x64": {
- "version": "1.381.0",
- "resolved": "https://registry.npmjs.org/@github/copilot-language-server-win32-x64/-/copilot-language-server-win32-x64-1.381.0.tgz",
- "integrity": "sha512-T0g0lojZucFUhtTGEgNZm5qxQlZJApQt0HJg5UM4UegHgPudLNv3xjBH4nWKdAt85JABQiOf0LRqKd2bkLHsFw==",
+ "version": "1.523.3",
+ "resolved": "https://registry.npmjs.org/@github/copilot-language-server-win32-x64/-/copilot-language-server-win32-x64-1.523.3.tgz",
+ "integrity": "sha512-q+B6QzV/fzeqIKwqwj1yoBnHKgcnMsOrrcx0JiCPTB29iN2AMcvjdR1Er933VRF1UYyr/NZSEgU25xQQGDyV8A==",
"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 4398cb94..bd713687 100644
--- a/Server/package.json
+++ b/Server/package.json
@@ -7,19 +7,19 @@
"build": "webpack"
},
"dependencies": {
- "@github/copilot-language-server": "1.381.0",
- "@github/copilot-language-server-darwin-arm64": "1.381.0",
- "@github/copilot-language-server-darwin-x64": "1.381.0",
+ "@github/copilot-language-server": "1.523.3",
+ "@github/copilot-language-server-darwin-arm64": "1.523.3",
+ "@github/copilot-language-server-darwin-x64": "1.523.3",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"monaco-editor": "0.52.2"
},
"devDependencies": {
"@types/node": "^22.15.17",
- "copy-webpack-plugin": "^13.0.1",
+ "copy-webpack-plugin": "^14.0.0",
"css-loader": "^7.1.2",
"style-loader": "^4.0.0",
- "terser-webpack-plugin": "^5.3.14",
+ "terser-webpack-plugin": "^5.4.0",
"ts-loader": "^9.5.4",
"typescript": "^5.8.3",
"webpack": "^5.99.9",
diff --git a/Tool/Package.swift b/Tool/Package.swift
index 541990d8..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"]),
@@ -22,6 +22,7 @@ let package = Package(
.library(name: "Persist", targets: ["Persist"]),
.library(name: "UserDefaultsObserver", targets: ["UserDefaultsObserver"]),
.library(name: "Workspace", targets: ["Workspace", "WorkspaceSuggestionService"]),
+ .library(name: "WorkspaceSuggestionService", targets: ["WorkspaceSuggestionService"]),
.library(name: "WebContentExtractor", targets: ["WebContentExtractor"]),
.library(
name: "SuggestionProvider",
@@ -65,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.
@@ -188,6 +190,7 @@ let package = Package(
dependencies: [
"SuggestionBasic",
"SuggestionProvider",
+ "TelemetryServiceProvider",
"Workspace",
.product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"),
]
@@ -196,9 +199,11 @@ let package = Package(
.target(
name: "SharedUIComponents",
dependencies: [
+ "AppKitExtension",
"Highlightr",
"Preferences",
"SuggestionBasic",
+ "Status",
"DebounceFunction",
"ConversationServiceProvider",
.product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
@@ -238,7 +243,7 @@ let package = Package(
]
),
- .target(name: "StatusBarItemView", dependencies: ["Cache"]),
+ .target(name: "StatusBarItemView", dependencies: ["Cache", "Status"]),
.target(
name: "Cache"
@@ -264,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")
]
),
@@ -279,6 +285,7 @@ let package = Package(
.target(name: "ConversationServiceProvider", dependencies: [
"GitHelper",
+ "SuggestionBasic",
.product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"),
.product(name: "LanguageServerProtocol", package: "LanguageServerProtocol"),
]),
@@ -301,6 +308,8 @@ let package = Package(
// MARK: - GitHub Copilot
+ .target(name: "NotificationCenterCoordinator"),
+
.target(
name: "GitHubCopilotService",
dependencies: [
@@ -316,6 +325,8 @@ let package = Package(
"SystemUtils",
"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
new file mode 100644
index 00000000..ff5948cc
--- /dev/null
+++ b/Tool/Sources/AXExtension/AXUIElement+Xcode.swift
@@ -0,0 +1,43 @@
+import AppKit
+import Foundation
+
+// Extension for xcode specifically
+public extension AXUIElement {
+ private static let XcodeWorkspaceWindowIdentifier = "Xcode.WorkspaceWindow"
+
+ var isSourceEditor: Bool {
+ description == "Source Editor"
+ }
+
+ var isEditorArea: Bool {
+ description == "editor area"
+ }
+
+ var isXcodeWorkspaceWindow: Bool {
+ self.description == Self.XcodeWorkspaceWindowIdentifier || self.identifier == Self.XcodeWorkspaceWindowIdentifier
+ }
+
+ var isXcodeOpenQuickly: Bool {
+ ["open_quickly"].contains(self.identifier)
+ }
+
+ var isXcodeAlert: Bool {
+ ["alert"].contains(self.label)
+ }
+
+ var isXcodeMenuBar: Bool {
+ ["menu bar", "menu bar item"].contains(self.description)
+ }
+
+ var isNavigator: Bool {
+ description == "navigator"
+ }
+
+ var isDescendantOfNavigator: Bool {
+ self.firstParent(where: \.isNavigator) != nil
+ }
+
+ var isNonNavigatorSourceEditor: Bool {
+ isSourceEditor && !isDescendantOfNavigator
+ }
+}
diff --git a/Tool/Sources/AXExtension/AXUIElement.swift b/Tool/Sources/AXExtension/AXUIElement.swift
index b7366398..6d0a2584 100644
--- a/Tool/Sources/AXExtension/AXUIElement.swift
+++ b/Tool/Sources/AXExtension/AXUIElement.swift
@@ -56,18 +56,6 @@ public extension AXUIElement {
(try? copyValue(key: kAXLabelValueAttribute)) ?? ""
}
- var isSourceEditor: Bool {
- description == "Source Editor"
- }
-
- var isEditorArea: Bool {
- description == "editor area"
- }
-
- var isXcodeWorkspaceWindow: Bool {
- description == "Xcode.WorkspaceWindow" || identifier == "Xcode.WorkspaceWindow"
- }
-
var selectedTextRange: ClosedRange? {
guard let value: AXValue = try? copyValue(key: kAXSelectedTextRangeAttribute)
else { return nil }
@@ -247,16 +235,16 @@ public extension AXUIElement {
}
func retrieveSourceEditor() -> AXUIElement? {
- if self.isSourceEditor { return self }
+ if isNonNavigatorSourceEditor { return self }
if self.isXcodeWorkspaceWindow {
- return self.firstChild(where: \.isSourceEditor)
+ return self.firstChild(where: \.isNonNavigatorSourceEditor)
}
guard let xcodeWorkspaceWindowElement = self.firstParent(where: \.isXcodeWorkspaceWindow)
else { return nil }
- return xcodeWorkspaceWindowElement.firstChild(where: \.isSourceEditor)
+ return xcodeWorkspaceWindowElement.firstChild(where: \.isNonNavigatorSourceEditor)
}
}
@@ -339,24 +327,24 @@ public extension AXUIElement {
func findSourceEditorElement(shouldRetry: Bool = true) -> AXUIElement? {
// 1. Check if the current element is a source editor
- if isSourceEditor {
+ if isNonNavigatorSourceEditor {
return self
}
// 2. Search for child that is a source editor
- if let sourceEditorChild = firstChild(where: \.isSourceEditor) {
+ if let sourceEditorChild = firstChild(where: \.isNonNavigatorSourceEditor) {
return sourceEditorChild
}
// 3. Search for parent that is a source editor (XcodeInspector's approach)
- if let sourceEditorParent = firstParent(where: \.isSourceEditor) {
+ if let sourceEditorParent = firstParent(where: \.isNonNavigatorSourceEditor) {
return sourceEditorParent
}
// 4. Search for parent that is an editor area
if let editorAreaParent = firstParent(where: \.isEditorArea) {
// 3.1 Search for child that is a source editor
- if let sourceEditorChild = editorAreaParent.firstChild(where: \.isSourceEditor) {
+ if let sourceEditorChild = editorAreaParent.firstChild(where: \.isNonNavigatorSourceEditor) {
return sourceEditorChild
}
}
@@ -366,7 +354,7 @@ public extension AXUIElement {
// 4.1 Search for child that is an editor area
if let editorAreaChild = xcodeWorkspaceWindowParent.firstChild(where: \.isEditorArea) {
// 4.2 Search for child that is a source editor
- if let sourceEditorChild = editorAreaChild.firstChild(where: \.isSourceEditor) {
+ if let sourceEditorChild = editorAreaChild.firstChild(where: \.isNonNavigatorSourceEditor) {
return sourceEditorChild
}
}
@@ -385,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 5af9a206..533a3c1e 100644
--- a/Tool/Sources/AXHelper/AXHelper.swift
+++ b/Tool/Sources/AXHelper/AXHelper.swift
@@ -2,6 +2,17 @@ import XPCShared
import XcodeInspector
import AppKit
+public enum AXHelperError: LocalizedError {
+ case failedToSetValue(AXError)
+
+ public var errorDescription: String? {
+ switch self {
+ case .failedToSetValue(let axError):
+ return "Failed to set focus element value by AccessibilityAPI: \(axError.rawValue)"
+ }
+ }
+}
+
public struct AXHelper {
public init() {}
@@ -26,6 +37,7 @@ public struct AXHelper {
if let onError = onError {
onError()
}
+ throw AXHelperError.failedToSetValue(error)
}
// recover selection range
@@ -88,7 +100,7 @@ public struct AXHelper {
}
public static func scrollSourceEditorToLine(_ lineNumber: Int, content: String, focusedElement: AXUIElement) {
- guard focusedElement.isSourceEditor,
+ guard focusedElement.isNonNavigatorSourceEditor,
let scrollBar = focusedElement.parent?.verticalScrollBar,
let linePosition = Self.getScrollPositionForLine(lineNumber, content: content)
else { return }
diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtension.swift b/Tool/Sources/BuiltinExtension/BuiltinExtension.swift
index 525b5c54..c4c9b8c7 100644
--- a/Tool/Sources/BuiltinExtension/BuiltinExtension.swift
+++ b/Tool/Sources/BuiltinExtension/BuiltinExtension.swift
@@ -3,6 +3,122 @@ import Foundation
import Preferences
import ConversationServiceProvider
import TelemetryServiceProvider
+import LanguageServerProtocol
+
+// Exported from `CopilotForXcodeKit`, as we need to modify the protocol for document change
+public protocol CopilotForXcodeExtensionCapability {
+ associatedtype TheSuggestionService: SuggestionServiceType
+ associatedtype TheChatService: ChatServiceType
+ associatedtype ThePromptToCodeService: PromptToCodeServiceType
+
+ /// The suggestion service.
+ ///
+ /// Provide a non nil value if the extension provides a suggestion service, even if
+ /// the extension is not yet ready to provide suggestions.
+ ///
+ /// If you don't have a suggestion service in this extension, simply ignore this property.
+ var suggestionService: TheSuggestionService? { get }
+ /// Not implemented yet.
+ var chatService: TheChatService? { get }
+ /// Not implemented yet.
+ var promptToCodeService: ThePromptToCodeService? { get }
+
+ // MARK: Optional Methods
+
+ /// Called when a workspace is opened.
+ ///
+ /// A workspace may have already been opened when the extension is activated.
+ /// Use ``HostServer/getExistedWorkspaces()`` to get all ``WorkspaceInfo`` instead.
+ func workspaceDidOpen(_ workspace: WorkspaceInfo)
+
+ /// Called when a workspace is closed.
+ func workspaceDidClose(_ workspace: WorkspaceInfo)
+
+ /// Called when a document is saved.
+ func workspace(_ workspace: WorkspaceInfo, didSaveDocumentAt documentURL: URL)
+
+ /// Called when a document is closed.
+ ///
+ /// - note: Copilot for Xcode doesn't know that a document is closed. It use
+ /// some mechanism to detect if the document is closed which is inaccurate and could be delayed.
+ func workspace(_ workspace: WorkspaceInfo, didCloseDocumentAt documentURL: URL)
+
+ /// Called when a document is opened.
+ ///
+ /// - note: Copilot for Xcode doesn't know that a document is opened. It use
+ /// some mechanism to detect if the document is opened which is inaccurate and could be delayed.
+ func workspace(_ workspace: WorkspaceInfo, didOpenDocumentAt documentURL: URL) async
+
+ /// Called when a document is changed.
+ ///
+ /// - attention: `content` could be nil if \
+ /// β’ the document is too large \
+ /// β’ the document is binary \
+ /// β’ the document is git ignored \
+ /// β’ the extension is not considered in-use by the host app \
+ /// β’ the extension has no permission to access the file \
+ /// \
+ /// If you still want to access the file content in these cases,
+ /// you will have to access the file by yourself, or call ``HostServer/getDocument(at:)``.
+ func workspace(
+ _ workspace: WorkspaceInfo,
+ didUpdateDocumentAt documentURL: URL,
+ content: String?,
+ contentChanges: [TextDocumentContentChangeEvent]?
+ ) async
+
+ /// Called occasionally to inform the extension how it is used in the app.
+ ///
+ /// The `usage` contains information like the current user-picked suggestion service, etc.
+ /// You can use this to determine if you would like to startup or dispose some resources.
+ ///
+ /// For example, if you are running a language server to provide suggestions, you may want to
+ /// kill the process when the suggestion service is no longer in use.
+ func extensionUsageDidChange(_ usage: ExtensionUsage)
+}
+
+public extension CopilotForXcodeExtensionCapability {
+ func xcodeDidBecomeActive() {}
+
+ func xcodeDidBecomeInactive() {}
+
+ func xcodeDidSwitchEditor() {}
+
+ func workspaceDidOpen(_: WorkspaceInfo) {}
+
+ func workspaceDidClose(_: WorkspaceInfo) {}
+
+ func workspace(_: WorkspaceInfo, didSaveDocumentAt _: URL) {}
+
+ func workspace(_: WorkspaceInfo, didCloseDocumentAt _: URL) {}
+
+ func workspace(_: WorkspaceInfo, didOpenDocumentAt _: URL) async {}
+
+ func workspace(
+ _ workspace: WorkspaceInfo,
+ didUpdateDocumentAt documentURL: URL,
+ content: String?,
+ contentChanges: [TextDocumentContentChangeEvent]? = nil
+ ) async {}
+
+ func extensionUsageDidChange(_: ExtensionUsage) {}
+}
+
+public extension CopilotForXcodeExtensionCapability
+where TheSuggestionService == NoSuggestionService
+{
+ var suggestionService: TheSuggestionService? { nil }
+}
+
+public extension CopilotForXcodeExtensionCapability
+where ThePromptToCodeService == NoPromptToCodeService
+{
+ var promptToCodeService: ThePromptToCodeService? { nil }
+}
+
+public extension CopilotForXcodeExtensionCapability where TheChatService == NoChatService {
+ var chatService: TheChatService? { nil }
+}
public typealias CopilotForXcodeCapability = CopilotForXcodeExtensionCapability & CopilotForXcodeChatCapability & CopilotForXcodeTelemetryCapability
diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift
index 81289093..fbf5852b 100644
--- a/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift
+++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift
@@ -56,30 +56,34 @@ public final class BuiltinExtensionConversationServiceProvider<
}
}
- public func createConversation(_ request: ConversationRequest, workspaceURL: URL?) async throws {
+ public func createConversation(
+ _ request: ConversationRequest, workspaceURL: URL?
+ ) async throws -> ConversationCreateResponse? {
guard let conversationService else {
Logger.service.error("Builtin chat service not found.")
- return
+ return nil
}
guard let workspaceInfo = await activeWorkspace(workspaceURL) else {
Logger.service.error("Could not get active workspace info")
- return
+ return nil
}
- try await conversationService.createConversation(request, workspace: workspaceInfo)
+ return try await conversationService.createConversation(request, workspace: workspaceInfo)
}
- public func createTurn(with conversationId: String, request: ConversationRequest, workspaceURL: URL?) async throws {
+ public func createTurn(
+ with conversationId: String, request: ConversationRequest, workspaceURL: URL?
+ ) async throws -> ConversationCreateResponse? {
guard let conversationService else {
Logger.service.error("Builtin chat service not found.")
- return
+ return nil
}
guard let workspaceInfo = await activeWorkspace(workspaceURL) else {
Logger.service.error("Could not get active workspace info")
- return
+ return nil
}
- try await conversationService
+ return try await conversationService
.createTurn(
with: conversationId,
request: request,
@@ -149,6 +153,19 @@ public final class BuiltinExtensionConversationServiceProvider<
return (try? await conversationService.templates(workspace: workspaceInfo))
}
+
+ public func modes() async throws -> [ConversationMode]? {
+ guard let conversationService else {
+ Logger.service.error("Builtin chat service not found.")
+ return nil
+ }
+ guard let workspaceInfo = await activeWorkspace() else {
+ Logger.service.error("Could not get active workspace info")
+ return nil
+ }
+
+ return (try? await conversationService.modes(workspace: workspaceInfo))
+ }
public func models() async throws -> [CopilotModel]? {
guard let conversationService else {
@@ -194,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/BuiltinExtension/BuiltinExtensionSuggestionServiceProvider.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionSuggestionServiceProvider.swift
index f6234ddf..4b09aeef 100644
--- a/Tool/Sources/BuiltinExtension/BuiltinExtensionSuggestionServiceProvider.swift
+++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionSuggestionServiceProvider.swift
@@ -29,8 +29,8 @@ public final class BuiltinExtensionSuggestionServiceProvider<
self.extensionManager = extensionManager
}
- var service: CopilotForXcodeKit.SuggestionServiceType? {
- extensionManager.extensions.first { $0 is T }?.suggestionService
+ var service: (SuggestionServiceType & NESSuggestionServiceType)? {
+ extensionManager.extensions.first { $0 is T }?.suggestionService as? (SuggestionServiceType & NESSuggestionServiceType)
}
struct BuiltinExtensionSuggestionServiceNotFoundError: Error, LocalizedError {
@@ -47,25 +47,22 @@ public final class BuiltinExtensionSuggestionServiceProvider<
Logger.service.error("Builtin suggestion service not found.")
throw BuiltinExtensionSuggestionServiceNotFoundError()
}
+
return try await service.getSuggestions(
- .init(
- fileURL: request.fileURL,
- relativePath: request.relativePath,
- language: .init(
- rawValue: languageIdentifierFromFileURL(request.fileURL).rawValue
- ) ?? .plaintext,
- content: request.content,
- originalContent: request.originalContent,
- cursorPosition: .init(
- line: request.cursorPosition.line,
- character: request.cursorPosition.character
- ),
- tabSize: request.tabSize,
- indentSize: request.indentSize,
- usesTabsForIndentation: request.usesTabsForIndentation,
- relevantCodeSnippets: request.relevantCodeSnippets.map { $0.converted }
- ),
- workspace: workspaceInfo
+ request.toCopilotForXcodeKitSuggestionRequest(), workspace: workspaceInfo
+ ).map { $0.converted }
+ }
+
+ public func getNESSuggestions(
+ _ request: SuggestionProvider.SuggestionRequest,
+ workspaceInfo: CopilotForXcodeKit.WorkspaceInfo
+ ) async throws -> [SuggestionBasic.CodeSuggestion] {
+ guard let service else {
+ Logger.service.error("Builtin suggestion service not found.")
+ throw BuiltinExtensionSuggestionServiceNotFoundError()
+ }
+ return try await service.getNESSuggestions(
+ request.toCopilotForXcodeKitSuggestionRequest(), workspace: workspaceInfo
).map { $0.converted }
}
@@ -121,6 +118,26 @@ extension SuggestionProvider.SuggestionRequest {
relevantCodeSnippets: relevantCodeSnippets.map(\.converted)
)
}
+
+ func toCopilotForXcodeKitSuggestionRequest() -> CopilotForXcodeKit.SuggestionRequest {
+ .init(
+ fileURL: self.fileURL,
+ relativePath: self.relativePath,
+ language: .init(
+ rawValue: languageIdentifierFromFileURL(self.fileURL).rawValue
+ ) ?? .plaintext,
+ content: self.content,
+ originalContent: self.originalContent,
+ cursorPosition: .init(
+ line: self.cursorPosition.line,
+ character: self.cursorPosition.character
+ ),
+ tabSize: self.tabSize,
+ indentSize: self.indentSize,
+ usesTabsForIndentation: self.usesTabsForIndentation,
+ relevantCodeSnippets: self.relevantCodeSnippets.map { $0.converted }
+ )
+ }
}
extension SuggestionBasic.CodeSuggestion {
diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtensionWorkspacePlugin.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionWorkspacePlugin.swift
index a03c34d1..4599f3b6 100644
--- a/Tool/Sources/BuiltinExtension/BuiltinExtensionWorkspacePlugin.swift
+++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionWorkspacePlugin.swift
@@ -1,5 +1,6 @@
import Foundation
import Workspace
+import LanguageServerProtocol
public final class BuiltinExtensionWorkspacePlugin: WorkspacePlugin {
let extensionManager: BuiltinExtensionManager
@@ -9,16 +10,20 @@ public final class BuiltinExtensionWorkspacePlugin: WorkspacePlugin {
super.init(workspace: workspace)
}
- override public func didOpenFilespace(_ filespace: Filespace) {
- notifyOpenFile(filespace: filespace)
+ override public func didOpenFilespace(_ filespace: Filespace) async {
+ await notifyOpenFile(filespace: filespace)
}
override public func didSaveFilespace(_ filespace: Filespace) {
notifySaveFile(filespace: filespace)
}
- override public func didUpdateFilespace(_ filespace: Filespace, content: String) {
- notifyUpdateFile(filespace: filespace, content: content)
+ override public func didUpdateFilespace(
+ _ filespace: Filespace,
+ content: String,
+ contentChanges: [TextDocumentContentChangeEvent]? = nil
+ ) async {
+ await notifyUpdateFile(filespace: filespace, content: content, contentChanges: contentChanges)
}
override public func didCloseFilespace(_ fileURL: URL) {
@@ -32,28 +37,29 @@ public final class BuiltinExtensionWorkspacePlugin: WorkspacePlugin {
}
}
- public func notifyOpenFile(filespace: Filespace) {
- Task {
- guard filespace.isTextReadable else { return }
- for ext in extensionManager.extensions {
- ext.workspace(
- .init(workspaceURL: workspaceURL, projectURL: projectRootURL),
- didOpenDocumentAt: filespace.fileURL
- )
- }
+ public func notifyOpenFile(filespace: Filespace) async {
+ guard filespace.isTextReadable else { return }
+ for ext in extensionManager.extensions {
+ await ext.workspace(
+ .init(workspaceURL: workspaceURL, projectURL: projectRootURL),
+ didOpenDocumentAt: filespace.fileURL
+ )
}
}
- public func notifyUpdateFile(filespace: Filespace, content: String) {
- Task {
- guard filespace.isTextReadable else { return }
- for ext in extensionManager.extensions {
- ext.workspace(
- .init(workspaceURL: workspaceURL, projectURL: projectRootURL),
- didUpdateDocumentAt: filespace.fileURL,
- content: content
- )
- }
+ public func notifyUpdateFile(
+ filespace: Filespace,
+ content: String,
+ contentChanges: [TextDocumentContentChangeEvent]? = nil
+ ) async {
+ guard filespace.isTextReadable else { return }
+ for ext in extensionManager.extensions {
+ await ext.workspace(
+ .init(workspaceURL: workspaceURL, projectURL: projectRootURL),
+ didUpdateDocumentAt: filespace.fileURL,
+ content: content,
+ contentChanges: contentChanges
+ )
}
}
diff --git a/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift b/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift
index 77a88484..5fd4bc26 100644
--- a/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift
+++ b/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift
@@ -9,10 +9,77 @@ public protocol ChatMemory {
}
public extension ChatMemory {
- /// Append a message to the history.
func appendMessage(_ message: ChatMessage) async {
await mutateHistory { history in
- if let index = history.firstIndex(where: { $0.id == message.id }) {
+ if let parentTurnId = message.parentTurnId {
+ history.removeAll { $0.id == message.id }
+
+ guard let parentIndex = history.firstIndex(where: { $0.id == parentTurnId }) else {
+ return
+ }
+
+ var parentMessage = history[parentIndex]
+
+ if !message.editAgentRounds.isEmpty {
+ var parentRounds = parentMessage.editAgentRounds
+
+ if let lastParentRoundIndex = parentRounds.indices.last {
+ var existingSubRounds = parentRounds[lastParentRoundIndex].subAgentRounds ?? []
+
+ for messageRound in message.editAgentRounds {
+ if let subIndex = existingSubRounds.firstIndex(where: { $0.roundId == messageRound.roundId }) {
+ existingSubRounds[subIndex].reply = existingSubRounds[subIndex].reply + messageRound.reply
+ mergeThinking(into: &existingSubRounds[subIndex].thinking, from: messageRound.thinking)
+ if let messageToolCalls = messageRound.toolCalls, !messageToolCalls.isEmpty {
+ var mergedToolCalls = existingSubRounds[subIndex].toolCalls ?? []
+ for newToolCall in messageToolCalls {
+ if let toolCallIndex = mergedToolCalls.firstIndex(where: { $0.id == newToolCall.id }) {
+ mergedToolCalls[toolCallIndex].status = newToolCall.status
+ if let toolType = newToolCall.toolType {
+ mergedToolCalls[toolCallIndex].toolType = toolType
+ }
+ if let progressMessage = newToolCall.progressMessage, !progressMessage.isEmpty {
+ mergedToolCalls[toolCallIndex].progressMessage = progressMessage
+ }
+ if let input = newToolCall.input, !input.isEmpty {
+ mergedToolCalls[toolCallIndex].input = input
+ }
+ if let inputMessage = newToolCall.inputMessage, !inputMessage.isEmpty {
+ mergedToolCalls[toolCallIndex].inputMessage = inputMessage
+ }
+ if let result = newToolCall.result, !result.isEmpty {
+ mergedToolCalls[toolCallIndex].result = result
+ }
+ if let resultDetails = newToolCall.resultDetails, !resultDetails.isEmpty {
+ mergedToolCalls[toolCallIndex].resultDetails = resultDetails
+ }
+ if let error = newToolCall.error, !error.isEmpty {
+ mergedToolCalls[toolCallIndex].error = error
+ }
+ if let invokeParams = newToolCall.invokeParams {
+ mergedToolCalls[toolCallIndex].invokeParams = invokeParams
+ }
+ if let title = newToolCall.title {
+ mergedToolCalls[toolCallIndex].title = title
+ }
+ } else {
+ mergedToolCalls.append(newToolCall)
+ }
+ }
+ existingSubRounds[subIndex].toolCalls = mergedToolCalls
+ }
+ } else {
+ existingSubRounds.append(messageRound)
+ }
+ }
+
+ parentRounds[lastParentRoundIndex].subAgentRounds = existingSubRounds
+ parentMessage.editAgentRounds = parentRounds
+ }
+ }
+
+ history[parentIndex] = parentMessage
+ } else if let index = history.firstIndex(where: { $0.id == message.id }) {
history[index].mergeMessage(with: message)
} else {
history.append(message)
@@ -44,26 +111,19 @@ public extension ChatMemory {
extension ChatMessage {
mutating func mergeMessage(with message: ChatMessage) {
- // merge content
self.content = self.content + message.content
- // merge references
var seen = Set()
- // without duplicated and keep order
self.references = (self.references + message.references).filter { seen.insert($0).inserted }
- // merge followUp
self.followUp = message.followUp ?? self.followUp
- // merge suggested title
self.suggestedTitle = message.suggestedTitle ?? self.suggestedTitle
- // merge error message
self.errorMessages = self.errorMessages + message.errorMessages
self.panelMessages = self.panelMessages + message.panelMessages
- // merge steps
if !message.steps.isEmpty {
var mergedSteps = self.steps
@@ -77,24 +137,31 @@ extension ChatMessage {
self.steps = mergedSteps
}
-
- // merge agent steps
+
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
self.codeReviewRound = message.codeReviewRound
- // merge file edits
self.fileEdits = mergeFileEdits(oldEdits: self.fileEdits, newEdits: message.fileEdits)
- // merge turn status
self.turnStatus = message.turnStatus ?? self.turnStatus
+
+ // merge modelName and billingMultiplier
+ self.modelName = message.modelName ?? self.modelName
+ self.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] {
@@ -103,15 +170,32 @@ 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! {
if let toolCallIndex = mergedToolCalls.firstIndex(where: { $0.id == newToolCall.id }) {
mergedToolCalls[toolCallIndex].status = newToolCall.status
+ if let toolType = newToolCall.toolType {
+ mergedToolCalls[toolCallIndex].toolType = toolType
+ }
if let progressMessage = newToolCall.progressMessage, !progressMessage.isEmpty {
mergedToolCalls[toolCallIndex].progressMessage = newToolCall.progressMessage
}
+ if let input = newToolCall.input, !input.isEmpty {
+ mergedToolCalls[toolCallIndex].input = input
+ }
+ if let inputMessage = newToolCall.inputMessage, !inputMessage.isEmpty {
+ mergedToolCalls[toolCallIndex].inputMessage = inputMessage
+ }
+ if let result = newToolCall.result, !result.isEmpty {
+ mergedToolCalls[toolCallIndex].result = result
+ }
+ if let resultDetails = newToolCall.resultDetails, !resultDetails.isEmpty {
+ mergedToolCalls[toolCallIndex].resultDetails = resultDetails
+ }
if let error = newToolCall.error, !error.isEmpty {
mergedToolCalls[toolCallIndex].error = newToolCall.error
}
@@ -124,6 +208,45 @@ extension ChatMessage {
}
mergedAgentRounds[index].toolCalls = mergedToolCalls
}
+
+ if let newSubAgentRounds = newRound.subAgentRounds, !newSubAgentRounds.isEmpty {
+ var mergedSubRounds = mergedAgentRounds[index].subAgentRounds ?? []
+ for newSubRound in newSubAgentRounds {
+ if let subIndex = mergedSubRounds.firstIndex(where: { $0.roundId == newSubRound.roundId }) {
+ mergedSubRounds[subIndex].reply = mergedSubRounds[subIndex].reply + newSubRound.reply
+
+ if let subToolCalls = newSubRound.toolCalls, !subToolCalls.isEmpty {
+ var mergedSubToolCalls = mergedSubRounds[subIndex].toolCalls ?? []
+ for newSubToolCall in subToolCalls {
+ if let toolCallIndex = mergedSubToolCalls.firstIndex(where: { $0.id == newSubToolCall.id }) {
+ mergedSubToolCalls[toolCallIndex].status = newSubToolCall.status
+ if let progressMessage = newSubToolCall.progressMessage, !progressMessage.isEmpty {
+ mergedSubToolCalls[toolCallIndex].progressMessage = newSubToolCall.progressMessage
+ }
+ if let error = newSubToolCall.error, !error.isEmpty {
+ mergedSubToolCalls[toolCallIndex].error = newSubToolCall.error
+ }
+ if let result = newSubToolCall.result, !result.isEmpty {
+ mergedSubToolCalls[toolCallIndex].result = result
+ }
+ if let resultDetails = newSubToolCall.resultDetails, !resultDetails.isEmpty {
+ mergedSubToolCalls[toolCallIndex].resultDetails = resultDetails
+ }
+ if let invokeParams = newSubToolCall.invokeParams {
+ mergedSubToolCalls[toolCallIndex].invokeParams = invokeParams
+ }
+ } else {
+ mergedSubToolCalls.append(newSubToolCall)
+ }
+ }
+ mergedSubRounds[subIndex].toolCalls = mergedSubToolCalls
+ }
+ } else {
+ mergedSubRounds.append(newSubRound)
+ }
+ }
+ mergedAgentRounds[index].subAgentRounds = mergedSubRounds
+ }
} else {
mergedAgentRounds.append(newRound)
}
@@ -149,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 917fbb78..868a13b3 100644
--- a/Tool/Sources/ChatAPIService/Models.swift
+++ b/Tool/Sources/ChatAPIService/Models.swift
@@ -106,6 +106,8 @@ public enum RequestType: String, Equatable, Codable {
case conversation, codeReview
}
+public let HardCodedToolRoundExceedErrorMessage: String = "Oops, maximum tool attempts reached. You can update the max tool requests in settings."
+public let SSLCertificateErrorMessage: String = "Unable to verify the SSL certificate. This often happens in enterprise environments with custom certificates. Try disabling **Proxy strict SSL** in the Proxy Settings."
public struct ChatMessage: Equatable, Codable {
public typealias ID = String
@@ -114,6 +116,8 @@ public struct ChatMessage: Equatable, Codable {
case user
case assistant
case system
+
+ public var isAssistant: Bool { self == .assistant }
}
public enum TurnStatus: String, Codable, Equatable {
@@ -154,9 +158,13 @@ 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?
+
public var panelMessages: [CopilotShowMessageParams]
public var codeReviewRound: CodeReviewRound?
@@ -170,6 +178,12 @@ public struct ChatMessage: Equatable, Codable {
public let requestType: RequestType
+ // 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
public var updatedAt: Date
@@ -188,11 +202,17 @@ public struct ChatMessage: Equatable, Codable {
rating: ConversationRating = .unrated,
steps: [ConversationProgressStep] = [],
editAgentRounds: [AgentRound] = [],
+ thinking: [MessageThinking] = [],
+ parentTurnId: String? = nil,
panelMessages: [CopilotShowMessageParams] = [],
codeReviewRound: CodeReviewRound? = nil,
fileEdits: [FileEdit] = [],
turnStatus: TurnStatus? = nil,
requestType: RequestType = .conversation,
+ modelName: String? = nil,
+ modelProviderName: String? = nil,
+ billingMultiplier: Float? = nil,
+ reasoningEffort: String? = nil,
createdAt: Date? = nil,
updatedAt: Date? = nil
) {
@@ -208,12 +228,18 @@ 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
self.codeReviewRound = codeReviewRound
self.fileEdits = fileEdits
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
@@ -248,10 +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
+ requestType: RequestType = .conversation,
+ modelName: String? = nil,
+ modelProviderName: String? = nil,
+ billingMultiplier: Float? = nil,
+ reasoningEffort: String? = nil
) {
self.init(
id: id,
@@ -264,10 +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
+ requestType: requestType,
+ modelName: modelName,
+ 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/AgentModeToolHelpers.swift b/Tool/Sources/ConversationServiceProvider/AgentModeToolHelpers.swift
new file mode 100644
index 00000000..4819cc8f
--- /dev/null
+++ b/Tool/Sources/ConversationServiceProvider/AgentModeToolHelpers.swift
@@ -0,0 +1,46 @@
+import Foundation
+
+/// Helper class for determining tool enabled state and interaction permissions based on agent mode
+public final class AgentModeToolHelpers {
+ public static func makeConfigurationKey(serverName: String, toolName: String) -> String {
+ return "\(serverName)/\(toolName)"
+ }
+
+ /// Determines if a tool should be enabled based on the selected agent mode
+ public static func isToolEnabledInMode(
+ configurationKey: String,
+ currentStatus: ToolStatus,
+ selectedMode: ConversationMode
+ ) -> Bool {
+ // For modes other than default agent mode, check if tool is in customTools list
+ if !selectedMode.isDefaultAgent {
+ guard let customTools = selectedMode.customTools else {
+ // If customTools is nil, no tools are enabled
+ return false
+ }
+
+ // If customTools is empty, no tools are enabled
+ if customTools.isEmpty {
+ return false
+ }
+
+ return customTools.contains(configurationKey)
+ }
+
+ // For built-in modes (Agent, Plan, etc.), use tool's current status
+ return currentStatus == .enabled
+ }
+
+ /// Determines if users should be allowed to interact with tool checkboxes
+ public static func isInteractionAllowed(selectedMode: ConversationMode) -> Bool {
+ // Allow interaction for built-in "Agent" mode and custom modes
+ if selectedMode.isDefaultAgent || !selectedMode.isBuiltIn {
+ return true
+ }
+
+ // Disable interaction for other built-in modes (like Plan)
+ return false
+ }
+
+ private init() {}
+}
diff --git a/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift b/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift
index 41c5a5ec..aaa13917 100644
--- a/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift
+++ b/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift
@@ -4,13 +4,14 @@ import CodableWrappers
import LanguageServerProtocol
public protocol ConversationServiceType {
- func createConversation(_ request: ConversationRequest, workspace: WorkspaceInfo) async throws
- func createTurn(with conversationId: String, request: ConversationRequest, workspace: WorkspaceInfo) async throws
+ func createConversation(_ request: ConversationRequest, workspace: WorkspaceInfo) async throws -> ConversationCreateResponse?
+ func createTurn(with conversationId: String, request: ConversationRequest, workspace: WorkspaceInfo) async throws -> ConversationCreateResponse?
func deleteTurn(with conversationId: String, turnId: String, workspace: WorkspaceInfo) async throws
func cancelProgress(_ workDoneToken: String, workspace: WorkspaceInfo) async throws
func rateConversation(turnId: String, rating: ConversationRating, workspace: WorkspaceInfo) async throws
func copyCode(request: CopyCodeRequest, workspace: WorkspaceInfo) async throws
func templates(workspace: WorkspaceInfo) async throws -> [ChatTemplate]?
+ func modes(workspace: WorkspaceInfo) async throws -> [ConversationMode]?
func models(workspace: WorkspaceInfo) async throws -> [CopilotModel]?
func notifyDidChangeWatchedFiles(_ event: DidChangeWatchedFilesEvent, workspace: WorkspaceInfo) async throws
func agents(workspace: WorkspaceInfo) async throws -> [ChatAgent]?
@@ -19,21 +20,24 @@ public protocol ConversationServiceType {
workspace: WorkspaceInfo,
changes: [ReviewChangesParams.Change]
) async throws -> CodeReviewResult?
+ func generateThinkingTitle(workspace: WorkspaceInfo, params: GenerateThinkingTitleParams) async throws -> GenerateThinkingTitleResponse?
}
public protocol ConversationServiceProvider {
- func createConversation(_ request: ConversationRequest, workspaceURL: URL?) async throws
- func createTurn(with conversationId: String, request: ConversationRequest, workspaceURL: URL?) async throws
+ func createConversation(_ request: ConversationRequest, workspaceURL: URL?) async throws -> ConversationCreateResponse?
+ func createTurn(with conversationId: String, request: ConversationRequest, workspaceURL: URL?) async throws -> ConversationCreateResponse?
func deleteTurn(with conversationId: String, turnId: String, workspaceURL: URL?) async throws
func stopReceivingMessage(_ workDoneToken: String, workspaceURL: URL?) async throws
func rateConversation(turnId: String, rating: ConversationRating, workspaceURL: URL?) async throws
func copyCode(_ request: CopyCodeRequest, workspaceURL: URL?) async throws
func templates() async throws -> [ChatTemplate]?
+ func modes() async throws -> [ConversationMode]?
func models() async throws -> [CopilotModel]?
func notifyDidChangeWatchedFiles(_ event: DidChangeWatchedFilesEvent, workspace: WorkspaceInfo) async throws
func agents() async throws -> [ChatAgent]?
func notifyChangeTextDocument(fileURL: URL, content: String, version: Int, workspaceURL: URL?) async throws
func reviewChanges(_ changes: [ReviewChangesParams.Change]) async throws -> CodeReviewResult?
+ func generateThinkingTitle(_ params: GenerateThinkingTitleParams) async throws -> GenerateThinkingTitleResponse?
}
public struct ConversationFileReference: Hashable, Codable, Equatable {
@@ -342,8 +346,10 @@ 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
public var userLanguage: String? = nil
public var turnId: String? = nil
@@ -358,8 +364,10 @@ public struct ConversationRequest {
references: [ConversationAttachedReference]? = nil,
model: String? = nil,
modelProviderName: String? = nil,
+ reasoningEffort: String? = nil,
turns: [TurnSchema] = [],
agentMode: Bool = false,
+ customChatModeId: String? = nil,
userLanguage: String?,
turnId: String? = nil
) {
@@ -373,8 +381,10 @@ public struct ConversationRequest {
self.references = references
self.model = model
self.modelProviderName = modelProviderName
+ self.reasoningEffort = reasoningEffort
self.turns = turns
self.agentMode = agentMode
+ self.customChatModeId = customChatModeId
self.userLanguage = userLanguage
self.turnId = turnId
}
@@ -446,46 +456,152 @@ public struct ConversationProgressStep: Codable, Equatable, Identifiable {
}
}
-public struct DidChangeWatchedFilesEvent: Codable {
- public var workspaceUri: String
- public var changes: [FileEvent]
-
- public init(workspaceUri: String, changes: [FileEvent]) {
- self.workspaceUri = workspaceUri
- self.changes = changes
+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)
}
}
-public struct AgentRound: Codable, Equatable {
- public let roundId: Int
- public var reply: String
- public var toolCalls: [AgentToolCall]?
-
- public init(roundId: Int, reply: String, toolCalls: [AgentToolCall]? = []) {
- self.roundId = roundId
- self.reply = reply
- self.toolCalls = toolCalls
+/// 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 AgentToolCall: Codable, Equatable, Identifiable {
- public let id: String
- public let name: String
- public var progressMessage: String?
- public var status: ToolCallStatus
- public var error: String?
- public var invokeParams: InvokeClientToolParams?
-
- public enum ToolCallStatus: String, Codable {
- case waitForConfirmation, accepted, running, completed, error, cancelled
+public struct ThinkingSection: Equatable {
+ public let title: String?
+ public let body: String
+
+ public init(title: String?, body: String) {
+ self.title = title
+ self.body = body
}
+}
- public init(id: String, name: String, progressMessage: String? = nil, status: ToolCallStatus, error: String? = nil, invokeParams: InvokeClientToolParams? = nil) {
- self.id = id
- self.name = name
- self.progressMessage = progressMessage
- self.status = status
- self.error = error
- self.invokeParams = invokeParams
+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]
+
+ public init(workspaceUri: String, changes: [FileEvent]) {
+ self.workspaceUri = workspaceUri
+ self.changes = changes
}
}
diff --git a/Tool/Sources/ConversationServiceProvider/LSPTypes+AgentRound.swift b/Tool/Sources/ConversationServiceProvider/LSPTypes+AgentRound.swift
new file mode 100644
index 00000000..95579d41
--- /dev/null
+++ b/Tool/Sources/ConversationServiceProvider/LSPTypes+AgentRound.swift
@@ -0,0 +1,180 @@
+import CopilotForXcodeKit
+import Foundation
+import LanguageServerProtocol
+
+public struct AgentRound: Codable, Equatable {
+ public let roundId: Int
+ public var reply: String
+ public var toolCalls: [AgentToolCall]?
+ public var subAgentRounds: [AgentRound]?
+ public 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 = []
+ }
+ }
+}
+
+public struct AgentToolCall: Codable, Equatable, Identifiable {
+ public let id: String
+ public let name: String
+ public var toolType: ToolType?
+ public var progressMessage: String?
+ public var status: ToolCallStatus
+ public var input: [String: AnyCodable]?
+ public var inputMessage: String?
+ public var error: String?
+ public var result: [ToolCallResultData]?
+ public var resultDetails: [ToolResultItem]?
+ public var invokeParams: InvokeClientToolParams?
+ public var title: String?
+
+ public enum ToolCallStatus: String, Codable {
+ case waitForConfirmation, accepted, running, completed, error, cancelled
+ }
+
+ public init(
+ id: String,
+ name: String,
+ toolType: ToolType? = nil,
+ progressMessage: String? = nil,
+ status: ToolCallStatus,
+ input: [String: AnyCodable]? = nil,
+ inputMessage: String? = nil,
+ error: String? = nil,
+ result: [ToolCallResultData]? = nil,
+ resultDetails: [ToolResultItem]? = nil,
+ invokeParams: InvokeClientToolParams? = nil,
+ title: String? = nil
+ ) {
+ self.id = id
+ self.name = name
+ self.toolType = toolType
+ self.progressMessage = progressMessage
+ self.status = status
+ self.input = input
+ self.inputMessage = inputMessage
+ self.error = error
+ self.result = result
+ self.resultDetails = resultDetails
+ self.invokeParams = invokeParams
+ self.title = title
+ }
+
+ public var isToolcallingLoopContinueTool: Bool {
+ self.name == "internal.tool_calling_loop_continue_confirmation"
+ }
+}
+
+public enum ToolCallResultData: Codable, Equatable {
+ case text(String)
+ case data(mimeType: String, data: String)
+
+ private enum CodingKeys: String, CodingKey {
+ case type, value
+ }
+
+ private enum ItemType: String, Codable {
+ case text, data
+ }
+
+ private struct DataValue: Codable {
+ let mimeType: String
+ let data: String
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ let type = try container.decode(ItemType.self, forKey: .type)
+
+ switch type {
+ case .text:
+ let value = try container.decode(String.self, forKey: .value)
+ self = .text(value)
+ case .data:
+ let value = try container.decode(DataValue.self, forKey: .value)
+ self = .data(mimeType: value.mimeType, data: value.data)
+ }
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+
+ switch self {
+ case .text(let string):
+ try container.encode(ItemType.text, forKey: .type)
+ try container.encode(string, forKey: .value)
+ case .data(let mimeType, let data):
+ try container.encode(ItemType.data, forKey: .type)
+ try container.encode(DataValue(mimeType: mimeType, data: data), forKey: .value)
+ }
+ }
+}
+
+public enum ToolResultItem: Codable, Equatable {
+ case text(String)
+ case fileLocation(FileLocation)
+
+ public struct FileLocation: Codable, Equatable {
+ public let uri: String
+ public let range: LSPRange
+
+ public init(uri: String, range: LSPRange) {
+ self.uri = uri
+ self.range = range
+ }
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case type, value
+ }
+
+ private enum ItemType: String, Codable {
+ case text, fileLocation
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ let type = try container.decode(ItemType.self, forKey: .type)
+
+ switch type {
+ case .text:
+ let value = try container.decode(String.self, forKey: .value)
+ self = .text(value)
+ case .fileLocation:
+ let value = try container.decode(FileLocation.self, forKey: .value)
+ self = .fileLocation(value)
+ }
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+
+ switch self {
+ case .text(let string):
+ try container.encode(ItemType.text, forKey: .type)
+ try container.encode(string, forKey: .value)
+ case .fileLocation(let location):
+ try container.encode(ItemType.fileLocation, forKey: .type)
+ try container.encode(location, forKey: .value)
+ }
+ }
+}
diff --git a/Tool/Sources/ConversationServiceProvider/LSPTypes.swift b/Tool/Sources/ConversationServiceProvider/LSPTypes.swift
index 901c6f81..eda80b2c 100644
--- a/Tool/Sources/ConversationServiceProvider/LSPTypes.swift
+++ b/Tool/Sources/ConversationServiceProvider/LSPTypes.swift
@@ -1,6 +1,7 @@
import Foundation
import JSONRPC
import LanguageServerProtocol
+import SuggestionBasic
// MARK: Conversation template
public struct ChatTemplate: Codable, Equatable {
@@ -23,6 +24,7 @@ public enum PromptTemplateScope: String, Codable, Equatable {
case agentPanel = "agent-panel"
case editor = "editor"
case inline = "inline"
+ case inlineAgent = "inline-agent"
case completion = "completion"
}
@@ -38,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
@@ -45,6 +48,10 @@ public struct CopilotModel: Codable, Equatable {
public let isChatFallback: Bool
public let capabilities: CopilotModelCapabilities
public let billing: CopilotModelBilling?
+ public let customModel: CopilotModelCustomModel?
+ public let degradationReason: String?
+ public let modelPickerCategory: String?
+ public let modelPickerPriceCategory: String?
}
public struct CopilotModelPolicy: Codable, Equatable {
@@ -54,19 +61,140 @@ 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 let promo: CopilotModelBillingPromo?
+
+ public init(
+ isPremium: Bool,
+ multiplier: Float,
+ tokenBasedBillingEnabled: Bool? = nil,
+ tokenPrices: CopilotModelBillingTokenPrices? = nil,
+ promo: CopilotModelBillingPromo? = nil
+ ) {
self.isPremium = isPremium
self.multiplier = multiplier
+ self.tokenBasedBillingEnabled = tokenBasedBillingEnabled
+ self.tokenPrices = tokenPrices
+ self.promo = promo
+ }
+}
+
+public struct CopilotModelBillingTokenPrices: Codable, Equatable, Hashable {
+ public let batchSize: Int?
+ public let `default`: CopilotModelTokenPriceTier?
+ public let longContext: CopilotModelTokenPriceTier?
+
+ public var cachePrice: Float? { `default`?.cachePrice ?? longContext?.cachePrice }
+ public var inputPrice: Float? { `default`?.inputPrice ?? longContext?.inputPrice }
+ public var outputPrice: Float? { `default`?.outputPrice ?? longContext?.outputPrice }
+ public var tokenUnit: Int? { batchSize }
+}
+
+public struct CopilotModelTokenPriceTier: Codable, Equatable, Hashable {
+ public let cachePrice: Float?
+ public let cacheWritePrice: Float?
+ public let inputPrice: Float?
+ public let outputPrice: Float?
+ public let maxContext: Int?
+}
+
+public struct CopilotModelBillingPromo: Codable, Equatable, Hashable {
+ public let id: String
+ public let discountPercent: Float
+ public let endsAt: String
+ public let message: String
+}
+
+public struct CopilotModelCustomModel: Codable, Equatable {
+ public let keyName: String?
+ public let ownerName: String?
+ public let ownerType: String?
+ public let provider: String?
+}
+
+// MARK: ChatModes
+public enum ChatMode: String, Codable {
+ case Ask = "Ask"
+ case Edit = "Edit"
+ case Agent = "Agent"
+ case InlineAgent = "InlineAgent"
+}
+
+public struct ConversationMode: Codable, Equatable {
+ public let id: String
+ public let name: String
+ public let kind: ChatMode
+ public let isBuiltIn: Bool
+ public let uri: String?
+ public let description: String?
+ public let customTools: [String]?
+ public let model: String?
+ public let handOffs: [HandOff]?
+
+ public var isDefaultAgent: Bool { id == "Agent" }
+
+ public static let `defaultAgent` = ConversationMode(
+ id: "Agent",
+ name: "Agent",
+ kind: .Agent,
+ isBuiltIn: true,
+ description: "Advanced agent mode with access to tools and capabilities"
+ )
+
+ public init(
+ id: String,
+ name: String,
+ kind: ChatMode,
+ isBuiltIn: Bool,
+ uri: String? = nil,
+ description: String? = nil,
+ customTools: [String]? = nil,
+ model: String? = nil,
+ handOffs: [HandOff]? = nil
+ ) {
+ self.id = id
+ self.name = name
+ self.kind = kind
+ self.isBuiltIn = isBuiltIn
+ self.uri = uri
+ self.description = description
+ self.customTools = customTools
+ self.model = model
+ self.handOffs = handOffs
+ }
+}
+
+public struct HandOff: Codable, Equatable {
+ public let agent: String
+ public let label: String
+ public let prompt: String
+ public let send: Bool?
+
+ public init(agent: String, label: String, prompt: String, send: Bool?) {
+ self.agent = agent
+ self.label = label
+ self.prompt = prompt
+ self.send = send
}
}
@@ -96,9 +224,20 @@ public struct RegisterToolsParams: Codable, Equatable {
}
public struct UpdateToolsStatusParams: Codable, Equatable {
+ public let chatModeKind: ChatMode?
+ public let customChatModeId: String?
+ public let workspaceFolders: [WorkspaceFolder]?
public let tools: [ToolStatusUpdate]
- public init(tools: [ToolStatusUpdate]) {
+ public init(
+ chatmodeKind: ChatMode? = nil,
+ customChatModeId: String? = nil,
+ workspaceFolders: [WorkspaceFolder]? = nil,
+ tools: [ToolStatusUpdate]
+ ) {
+ self.chatModeKind = chatmodeKind
+ self.customChatModeId = customChatModeId
+ self.workspaceFolders = workspaceFolders
self.tools = tools
}
}
@@ -435,6 +574,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
@@ -462,8 +615,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
@@ -472,7 +625,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,
@@ -481,6 +634,7 @@ public struct ReviewComment: Codable, Equatable, Hashable {
severity: String,
suggestion: String?
) {
+ self.id = UUID().uuidString
self.uri = uri
self.range = range
self.message = message
@@ -488,6 +642,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 {
@@ -497,3 +666,181 @@ public struct CodeReviewResult: Codable, Equatable {
self.comments = comments
}
}
+
+
+// MARK: - Conversation / Turn
+
+public enum ConversationSource: String, Codable {
+ case panel, inline
+}
+
+public struct FileReference: Codable, Equatable, Hashable {
+ public var type: String = "file"
+ public let uri: String
+ public let position: Position?
+ public let visibleRange: SuggestionBasic.CursorRange?
+ public let selection: SuggestionBasic.CursorRange?
+ public let openedAt: String?
+ public let activeAt: String?
+}
+
+public struct DirectoryReference: Codable, Equatable, Hashable {
+ public var type: String = "directory"
+ public let uri: String
+}
+
+public enum Reference: Codable, Equatable, Hashable {
+ case file(FileReference)
+ case directory(DirectoryReference)
+
+ public func encode(to encoder: Encoder) throws {
+ switch self {
+ case .file(let fileRef):
+ try fileRef.encode(to: encoder)
+ case .directory(let directoryRef):
+ try directoryRef.encode(to: encoder)
+ }
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case type
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ let type = try container.decode(String.self, forKey: .type)
+
+ switch type {
+ case "file":
+ let fileRef = try FileReference(from: decoder)
+ self = .file(fileRef)
+ case "directory":
+ let directoryRef = try DirectoryReference(from: decoder)
+ self = .directory(directoryRef)
+ default:
+ throw DecodingError.dataCorrupted(
+ DecodingError.Context(
+ codingPath: decoder.codingPath,
+ debugDescription: "Unknown reference type: \(type)"
+ )
+ )
+ }
+ }
+
+ public static func from(_ ref: ConversationAttachedReference) -> Reference {
+ switch ref {
+ case .file(let fileRef):
+ return .file(
+ .init(
+ uri: fileRef.url.absoluteString,
+ position: nil,
+ visibleRange: nil,
+ selection: nil,
+ openedAt: nil,
+ activeAt: nil
+ )
+ )
+ case .directory(let directoryRef):
+ return .directory(.init(uri: directoryRef.url.absoluteString))
+ }
+ }
+}
+
+public struct 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
+ public let agentSlug: String?
+ public let modelName: String?
+ public let modelProviderName: String?
+ public let billingMultiplier: Float?
+ public let modelInfo: ConversationModelInfo?
+}
+
+public struct ConversationCreateParams: Codable {
+ public var workDoneToken: String
+ public var turns: [TurnSchema]
+ public var capabilities: Capabilities
+ public var textDocument: Doc?
+ public var references: [Reference]?
+ public var computeSuggestions: Bool?
+ public var source: ConversationSource?
+ public var workspaceFolder: String?
+ public var workspaceFolders: [WorkspaceFolder]?
+ public var ignoredSkills: [String]?
+ public var model: String?
+ public var modelProviderName: String?
+ public var 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?
+
+ public init(skills: [String], allSkills: Bool? = nil) {
+ self.skills = skills
+ self.allSkills = allSkills
+ }
+ }
+
+ public init(
+ workDoneToken: String,
+ turns: [TurnSchema],
+ capabilities: Capabilities,
+ textDocument: Doc? = nil,
+ references: [Reference]? = nil,
+ computeSuggestions: Bool? = nil,
+ source: ConversationSource? = nil,
+ workspaceFolder: String? = nil,
+ workspaceFolders: [WorkspaceFolder]? = nil,
+ ignoredSkills: [String]? = nil,
+ model: String? = nil,
+ modelProviderName: String? = nil,
+ modelInfo: ConversationModelInfo? = nil,
+ chatMode: String? = nil,
+ customChatModeId: String? = nil,
+ needToolCallConfirmation: Bool? = nil,
+ userLanguage: String? = nil
+ ) {
+ self.workDoneToken = workDoneToken
+ self.turns = turns
+ self.capabilities = capabilities
+ self.textDocument = textDocument
+ self.references = references
+ self.computeSuggestions = computeSuggestions
+ self.source = source
+ self.workspaceFolder = workspaceFolder
+ self.workspaceFolders = workspaceFolders
+ self.ignoredSkills = ignoredSkills
+ self.model = model
+ self.modelProviderName = modelProviderName
+ self.modelInfo = modelInfo
+ self.chatMode = chatMode
+ self.customChatModeId = customChatModeId
+ self.needToolCallConfirmation = needToolCallConfirmation
+ self.userLanguage = userLanguage
+ }
+}
+
+// MARK: - ConversationErrorCode
+public enum ConversationErrorCode: Int {
+ // -1: Unknown error, used when the error may not be user friendly.
+ case unknown = -1
+ // 0: Default error code, for backward compatibility with Copilot Chat.
+ case `default` = 0
+ case toolRoundExceedError = 10000
+}
diff --git a/Tool/Sources/ConversationServiceProvider/PromptType.swift b/Tool/Sources/ConversationServiceProvider/PromptType.swift
new file mode 100644
index 00000000..6a896746
--- /dev/null
+++ b/Tool/Sources/ConversationServiceProvider/PromptType.swift
@@ -0,0 +1,123 @@
+import Foundation
+
+public enum PromptType: String, CaseIterable, Equatable {
+ case instructions = "instructions"
+ case prompt = "prompt"
+ case agent = "agent"
+
+ /// The directory name under .github where files of this type are stored
+ public var directoryName: String {
+ switch self {
+ case .instructions:
+ return "instructions"
+ case .prompt:
+ return "prompts"
+ case .agent:
+ return "agents"
+ }
+ }
+
+ /// The file extension for this prompt type
+ public var fileExtension: String {
+ switch self {
+ case .instructions:
+ return ".instructions.md"
+ case .prompt:
+ return ".prompt.md"
+ case .agent:
+ return ".agent.md"
+ }
+ }
+
+ /// Human-readable name for display purposes
+ public var displayName: String {
+ switch self {
+ case .instructions:
+ return "Instruction File"
+ case .prompt:
+ return "Prompt File"
+ case .agent:
+ return "Agent File"
+ }
+ }
+
+ /// Human-readable name for settings
+ public var settingTitle: String {
+ switch self {
+ case .instructions:
+ return "Custom Instructions"
+ case .prompt:
+ return "Prompt Files"
+ case .agent:
+ return "Agent Files"
+ }
+ }
+
+ /// Description for the prompt type
+ public var description: String {
+ switch self {
+ case .instructions:
+ return "Configure `.github/instructions/*.instructions.md` files scoped to specific file patterns or tasks."
+ case .prompt:
+ return "Configure `.github/prompts/*.prompt.md` files for reusable prompts. Trigger with '/' commands in the Chat view."
+ case .agent:
+ return "Configure `.github/agents/*.agent.md` files for autonomous agent tasks. Agents can perform multi-step operations."
+ }
+ }
+
+ /// Default template content for new files
+ public var defaultTemplate: String {
+ switch self {
+ case .instructions:
+ return """
+ ---
+ applyTo: '**'
+ ---
+ Provide project context and coding guidelines that AI should follow when generating code, or answering questions.
+
+ """
+ case .prompt:
+ return """
+ ---
+ description: Prompt Description
+ ---
+ Define the task to achieve, including specific requirements, constraints, and success criteria.
+
+ """
+ case .agent:
+ return """
+ ---
+ description: 'Describe what this custom agent does and when to use it.'
+ tools: []
+ ---
+ Define what this custom agent accomplishes for the user, when to use it, and the edges it won't cross. Specify its ideal inputs/outputs, the tools it may call, and how it reports progress or asks for help.
+
+ """
+ }
+ }
+
+ /// Get the help link for this prompt type. Requires the editor plugin version string.
+ public func helpLink(editorPluginVersion: String) -> String {
+ let version = editorPluginVersion == "0.0.0" ? "main" : editorPluginVersion
+
+ switch self {
+ case .instructions:
+ return "https://github.com/github/CopilotForXcode/blob/\(version)/Docs/CustomInstructions.md"
+ case .prompt:
+ return "https://github.com/github/CopilotForXcode/blob/\(version)/Docs/PromptFiles.md"
+ case .agent:
+ return "https://github.com/github/CopilotForXcode/blob/\(version)/Docs/AgentFiles.md"
+ }
+ }
+
+ /// Get the full file path for a given name and project URL
+ public func getFilePath(fileName: String, projectURL: URL) -> URL {
+ let directory = getDirectoryPath(projectURL: projectURL)
+ return directory.appendingPathComponent("\(fileName)\(fileExtension)")
+ }
+
+ /// Get the directory path for this prompt type
+ public func getDirectoryPath(projectURL: URL) -> URL {
+ return projectURL.appendingPathComponent(".github/\(directoryName)")
+ }
+}
diff --git a/Tool/Sources/ConversationServiceProvider/ToolNames.swift b/Tool/Sources/ConversationServiceProvider/ToolNames.swift
index 6040f4be..8ee3e077 100644
--- a/Tool/Sources/ConversationServiceProvider/ToolNames.swift
+++ b/Tool/Sources/ConversationServiceProvider/ToolNames.swift
@@ -7,3 +7,16 @@ public enum ToolName: String, Codable {
case createFile = "create_file"
case fetchWebPage = "fetch_webpage"
}
+
+public enum ServerToolName: String, Codable {
+ case readFile = "read_file"
+ case findFiles = "file_search"
+ case findTextInFiles = "grep_search"
+ case listDir = "list_dir"
+ case replaceString = "replace_string_in_file"
+ case codebase = "semantic_search"
+}
+
+public enum CopilotToolName: String, Codable {
+ case readFile = "copilot.read_file"
+}
diff --git a/Tool/Sources/GitHubCopilotService/Conversation/CompressionHandler.swift b/Tool/Sources/GitHubCopilotService/Conversation/CompressionHandler.swift
new file mode 100644
index 00000000..841d75d7
--- /dev/null
+++ b/Tool/Sources/GitHubCopilotService/Conversation/CompressionHandler.swift
@@ -0,0 +1,14 @@
+import Combine
+import Foundation
+
+public protocol CompressionHandler {
+ var onCompressionStarted: PassthroughSubject { get } // conversationId
+ var onCompressionCompleted: PassthroughSubject { get }
+}
+
+public final class CompressionHandlerImpl: CompressionHandler {
+ public static let shared = CompressionHandlerImpl()
+
+ public var onCompressionStarted = PassthroughSubject()
+ public var onCompressionCompleted = PassthroughSubject()
+}
diff --git a/Tool/Sources/GitHubCopilotService/Conversation/DynamicOAuthRequestHandler.swift b/Tool/Sources/GitHubCopilotService/Conversation/DynamicOAuthRequestHandler.swift
new file mode 100644
index 00000000..977396c4
--- /dev/null
+++ b/Tool/Sources/GitHubCopilotService/Conversation/DynamicOAuthRequestHandler.swift
@@ -0,0 +1,293 @@
+import AppKit
+import Combine
+import Foundation
+import JSONRPC
+import LanguageServerProtocol
+import Logger
+
+public protocol DynamicOAuthRequestHandler {
+ func handleDynamicOAuthRequest(
+ _ request: DynamicOAuthRequest,
+ completion: @escaping (AnyJSONRPCResponse) -> Void
+ )
+}
+
+public final class DynamicOAuthRequestHandlerImpl: NSObject, DynamicOAuthRequestHandler {
+ public static let shared = DynamicOAuthRequestHandlerImpl()
+
+ // MARK: - Constants
+
+ private enum LayoutConstants {
+ static let containerWidth: CGFloat = 450
+ static let fieldWidth: CGFloat = 330
+ static let labelWidth: CGFloat = 100
+ static let labelX: CGFloat = 4
+ static let fieldX: CGFloat = 100
+
+ static let spacing: CGFloat = 8
+ static let hintSpacing: CGFloat = 4
+ static let labelHeight: CGFloat = 17
+ static let fieldHeight: CGFloat = 28
+ static let labelVerticalOffset: CGFloat = 6
+
+ static let hintFontSize: CGFloat = 11
+ static let regularFontSize: CGFloat = 13
+ }
+
+ private enum Strings {
+ static let clientIdLabel = "Client ID *"
+ static let clientSecretLabel = "Client Secret"
+ static let clientIdPlaceholder = "OAuth client ID (azye39d...)"
+ static let clientSecretPlaceholder = "OAuth client secret (wer32o50f...) or leave it blank"
+ static let okButton = "OK"
+ static let cancelButton = "Cancel"
+ }
+
+ public func handleDynamicOAuthRequest(
+ _ request: DynamicOAuthRequest,
+ completion: @escaping (AnyJSONRPCResponse) -> Void
+ ) {
+ guard let params = request.params else { return }
+ Logger.gitHubCopilot.debug("Received Dynamic OAuth Request: \(params)")
+ Task { @MainActor in
+ let response = self.dynamicOAuthRequestAlert(params)
+ let jsonResult = try? JSONEncoder().encode(response)
+ let jsonValue = (try? JSONDecoder().decode(JSONValue.self, from: jsonResult ?? Data())) ?? JSONValue.null
+ completion(AnyJSONRPCResponse(id: request.id, result: jsonValue))
+ }
+ }
+
+ @MainActor
+ func dynamicOAuthRequestAlert(_ params: DynamicOAuthParams) -> DynamicOAuthResponse? {
+ let alert = configureAlert(with: params)
+ let (clientIdField, clientSecretField) = createAccessoryView(for: alert, params: params)
+
+ let modalResponse = alert.runModal()
+
+ return handleAlertResponse(
+ modalResponse,
+ clientIdField: clientIdField,
+ clientSecretField: clientSecretField
+ )
+ }
+
+ // MARK: - Alert Configuration
+
+ @MainActor
+ private func configureAlert(with params: DynamicOAuthParams) -> NSAlert {
+ let alert = NSAlert()
+ alert.messageText = params.header ?? params.title
+ alert.informativeText = params.detail
+ alert.alertStyle = .warning
+ alert.addButton(withTitle: Strings.okButton)
+ alert.addButton(withTitle: Strings.cancelButton)
+ return alert
+ }
+
+ // MARK: - Accessory View Creation
+
+ @MainActor
+ private func createAccessoryView(
+ for alert: NSAlert,
+ params: DynamicOAuthParams
+ ) -> (clientIdField: NSTextField, clientSecretField: NSSecureTextField) {
+ let (clientIdHint, clientIdHintHeight) = createHintLabel(
+ text: params.inputs.first(where: { $0.value == "clientId" })?.description ?? ""
+ )
+
+ let (clientSecretHint, clientSecretHintHeight) = createHintLabel(
+ text: params.inputs.first(where: { $0.value == "clientSecret" })?.description ?? ""
+ )
+
+ let totalHeight = calculateTotalHeight(
+ clientIdHintHeight: clientIdHintHeight,
+ clientSecretHintHeight: clientSecretHintHeight
+ )
+
+ let containerView = NSView(frame: NSRect(
+ x: 0,
+ y: 0,
+ width: LayoutConstants.containerWidth,
+ height: totalHeight
+ ))
+
+ let clientIdField = NSTextField()
+ let clientSecretField = NSSecureTextField()
+
+ layoutComponents(
+ in: containerView,
+ clientIdField: clientIdField,
+ clientSecretField: clientSecretField,
+ clientIdHint: clientIdHint,
+ clientSecretHint: clientSecretHint,
+ clientIdHintHeight: clientIdHintHeight,
+ clientSecretHintHeight: clientSecretHintHeight,
+ params: params
+ )
+
+ alert.accessoryView = containerView
+
+ return (clientIdField, clientSecretField)
+ }
+
+ // MARK: - Component Creation
+
+ @MainActor
+ private func createHintLabel(text: String) -> (label: NSTextField, height: CGFloat) {
+ let hint = NSTextField(wrappingLabelWithString: text)
+ hint.font = NSFont.systemFont(ofSize: LayoutConstants.hintFontSize)
+ hint.textColor = NSColor.secondaryLabelColor
+ let height = hint.sizeThatFits(NSSize(
+ width: LayoutConstants.fieldWidth,
+ height: CGFloat.greatestFiniteMagnitude
+ )).height
+ return (hint, height)
+ }
+
+ @MainActor
+ private func createInputField(placeholder: String) -> NSTextField {
+ let field = NSTextField()
+ field.placeholderString = placeholder
+ field.font = NSFont.systemFont(ofSize: LayoutConstants.regularFontSize)
+ field.isEditable = true
+ return field
+ }
+
+ @MainActor
+ private func createSecureField(placeholder: String) -> NSSecureTextField {
+ let field = NSSecureTextField()
+ field.placeholderString = placeholder
+ field.font = NSFont.systemFont(ofSize: LayoutConstants.regularFontSize)
+ field.isEditable = true
+ return field
+ }
+
+ @MainActor
+ private func createLabel(text: String) -> NSTextField {
+ let label = NSTextField(labelWithString: text)
+ label.font = NSFont.systemFont(ofSize: LayoutConstants.regularFontSize)
+ label.alignment = .left
+ return label
+ }
+
+ // MARK: - Layout
+
+ private func calculateTotalHeight(
+ clientIdHintHeight: CGFloat,
+ clientSecretHintHeight: CGFloat
+ ) -> CGFloat {
+ return clientSecretHintHeight + LayoutConstants.hintSpacing + LayoutConstants.fieldHeight +
+ LayoutConstants.spacing + clientIdHintHeight + LayoutConstants.hintSpacing +
+ LayoutConstants.fieldHeight
+ }
+
+ @MainActor
+ private func layoutComponents(
+ in containerView: NSView,
+ clientIdField: NSTextField,
+ clientSecretField: NSSecureTextField,
+ clientIdHint: NSTextField,
+ clientSecretHint: NSTextField,
+ clientIdHintHeight: CGFloat,
+ clientSecretHintHeight: CGFloat,
+ params: DynamicOAuthParams
+ ) {
+ var currentY: CGFloat = 0
+
+ // Client Secret section (bottom)
+ layoutFieldSection(
+ in: containerView,
+ field: clientSecretField,
+ label: createLabel(text: Strings.clientSecretLabel),
+ hint: clientSecretHint,
+ hintHeight: clientSecretHintHeight,
+ placeholder: params.inputs.first(where: { $0.value == "clientSecret" })?.placeholder ?? Strings.clientSecretPlaceholder,
+ currentY: ¤tY,
+ isLastSection: false
+ )
+
+ // Client ID section (top)
+ layoutFieldSection(
+ in: containerView,
+ field: clientIdField,
+ label: createLabel(text: Strings.clientIdLabel),
+ hint: clientIdHint,
+ hintHeight: clientIdHintHeight,
+ placeholder: params.inputs.first(where: { $0.value == "clientId" })?.placeholder ?? Strings.clientIdPlaceholder,
+ currentY: ¤tY,
+ isLastSection: true
+ )
+ }
+
+ @MainActor
+ private func layoutFieldSection(
+ in containerView: NSView,
+ field: NSTextField,
+ label: NSTextField,
+ hint: NSTextField,
+ hintHeight: CGFloat,
+ placeholder: String,
+ currentY: inout CGFloat,
+ isLastSection: Bool
+ ) {
+ // Position hint
+ hint.frame = NSRect(
+ x: LayoutConstants.fieldX,
+ y: currentY,
+ width: LayoutConstants.fieldWidth,
+ height: hintHeight
+ )
+ currentY += hintHeight + LayoutConstants.hintSpacing
+
+ // Position field
+ field.frame = NSRect(
+ x: LayoutConstants.fieldX,
+ y: currentY,
+ width: LayoutConstants.fieldWidth,
+ height: LayoutConstants.fieldHeight
+ )
+ field.placeholderString = placeholder
+
+ // Position label
+ label.frame = NSRect(
+ x: LayoutConstants.labelX,
+ y: currentY + LayoutConstants.labelVerticalOffset,
+ width: LayoutConstants.labelWidth,
+ height: LayoutConstants.labelHeight
+ )
+
+ // Add to container
+ containerView.addSubview(label)
+ containerView.addSubview(field)
+ containerView.addSubview(hint)
+
+ if !isLastSection {
+ currentY += LayoutConstants.fieldHeight + LayoutConstants.spacing
+ }
+ }
+
+ // MARK: - Response Handling
+
+ private func handleAlertResponse(
+ _ response: NSApplication.ModalResponse,
+ clientIdField: NSTextField,
+ clientSecretField: NSSecureTextField
+ ) -> DynamicOAuthResponse? {
+ guard response == .alertFirstButtonReturn else {
+ return nil
+ }
+
+ let clientId = clientIdField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !clientId.isEmpty else {
+ Logger.gitHubCopilot.info("Client ID is required but was not provided")
+ return nil
+ }
+
+ let clientSecret = clientSecretField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
+
+ return DynamicOAuthResponse(
+ clientId: clientId,
+ clientSecret: clientSecret
+ )
+ }
+}
diff --git a/Tool/Sources/GitHubCopilotService/Conversation/MCPOAuthRequestHandler.swift b/Tool/Sources/GitHubCopilotService/Conversation/MCPOAuthRequestHandler.swift
deleted file mode 100644
index 47ea5017..00000000
--- a/Tool/Sources/GitHubCopilotService/Conversation/MCPOAuthRequestHandler.swift
+++ /dev/null
@@ -1,67 +0,0 @@
-import JSONRPC
-import Foundation
-import Combine
-import Logger
-import AppKit
-
-public protocol MCPOAuthRequestHandler {
- func handleShowOAuthMessage(
- _ request: MCPOAuthRequest,
- completion: @escaping (
- AnyJSONRPCResponse
- ) -> Void
- )
-}
-
-public final class MCPOAuthRequestHandlerImpl: MCPOAuthRequestHandler {
- public static let shared = MCPOAuthRequestHandlerImpl()
-
- public func handleShowOAuthMessage(_ request: MCPOAuthRequest, completion: @escaping (AnyJSONRPCResponse) -> Void) {
- guard let params = request.params else { return }
- Logger.gitHubCopilot.debug("Received MCP OAuth Request: \(params)")
- Task { @MainActor in
- let confirmResult = showMCPOAuthAlert(params)
- let jsonResult = try? JSONEncoder().encode(MCPOAuthResponse(confirm: confirmResult))
- let jsonValue = (try? JSONDecoder().decode(JSONValue.self, from: jsonResult ?? Data())) ?? JSONValue.null
- completion(AnyJSONRPCResponse(id: request.id, result: jsonValue))
- }
- }
-
- @MainActor
- func showMCPOAuthAlert(_ params: MCPOAuthRequestParams) -> Bool {
- let alert = NSAlert()
- let mcpConfigString = UserDefaults.shared.value(for: \.gitHubCopilotMCPConfig)
-
- var serverName = params.mcpServer // Default fallback
-
- if let mcpConfigData = mcpConfigString.data(using: .utf8),
- let mcpConfig = try? JSONDecoder().decode(JSONValue.self, from: mcpConfigData) {
- // Iterate through the servers to find a match for the mcpServer URL
- if case .hash(let serversDict) = mcpConfig {
- for (userDefinedName, serverConfig) in serversDict {
- if let url = serverConfig["url"]?.stringValue {
- // Check if the mcpServer URL matches the configured URL
- if params.mcpServer.contains(url) || url.contains(params.mcpServer) {
- serverName = userDefinedName
- break
- }
- }
- }
- }
- }
-
- alert.messageText = "GitHub Copilot"
- alert.informativeText = "The MCP Server Definition '\(serverName)' wants to authenticate to \(params.authLabel)."
- alert.alertStyle = .informational
-
- alert.addButton(withTitle: "Continue")
- alert.addButton(withTitle: "Cancel")
-
- let response = alert.runModal()
- if response == .alertFirstButtonReturn {
- return true
- } else {
- return false
- }
- }
-}
diff --git a/Tool/Sources/GitHubCopilotService/Conversation/ShowMessageRequestHandler.swift b/Tool/Sources/GitHubCopilotService/Conversation/ShowMessageRequestHandler.swift
index cf137aa3..05e811bf 100644
--- a/Tool/Sources/GitHubCopilotService/Conversation/ShowMessageRequestHandler.swift
+++ b/Tool/Sources/GitHubCopilotService/Conversation/ShowMessageRequestHandler.swift
@@ -1,22 +1,89 @@
import JSONRPC
+import Foundation
import Combine
+import Logger
+import AppKit
+import LanguageServerProtocol
+import NotificationCenterCoordinator
+import UserNotifications
public protocol ShowMessageRequestHandler {
- var onShowMessage: PassthroughSubject<(ShowMessageRequest, (AnyJSONRPCResponse) -> Void), Never> { get }
- func handleShowMessage(
+ func handleShowMessageRequest(
_ request: ShowMessageRequest,
- completion: @escaping (
- AnyJSONRPCResponse
- ) -> Void
+ callback: @escaping @Sendable (Result>) async -> Void
)
}
public final class ShowMessageRequestHandlerImpl: ShowMessageRequestHandler {
public static let shared = ShowMessageRequestHandlerImpl()
+
+ private init() {}
+
+ public func handleShowMessageRequest(
+ _ request: ShowMessageRequest,
+ callback: @escaping @Sendable (Result>) async -> Void
+ ) {
+ guard let params = request.params else { return }
+ Logger.gitHubCopilot.debug("Received Show Message Request: \(params)")
+ Task { @MainActor in
+ await NotificationCenterCoordinator.shared.setupIfNeeded()
+
+ let actionCount = params.actions?.count ?? 0
+
+ // Use notification for messages with no action, alert for messages with actions
+ if actionCount == 0 {
+ await showMessageRequestNotification(params)
+ await callback(.success(nil))
+ } else {
+ let selectedAction = showMessageRequestAlert(params)
+ await callback(.success(selectedAction))
+ }
+ }
+ }
+
+ @MainActor
+ func showMessageRequestNotification(_ params: ShowMessageRequestParams) async {
+ let content = UNMutableNotificationContent()
+ content.title = "GitHub Copilot for Xcode"
+ content.body = params.message
+ content.sound = .default
+
+ let request = UNNotificationRequest(
+ identifier: UUID().uuidString,
+ content: content,
+ trigger: nil
+ )
+
+ do {
+ try await UNUserNotificationCenter.current().add(request)
+ } catch {
+ Logger.gitHubCopilot.error("Failed to show notification: \(error)")
+ }
+ }
- public let onShowMessage: PassthroughSubject<(ShowMessageRequest, (AnyJSONRPCResponse) -> Void), Never> = .init()
+ @MainActor
+ func showMessageRequestAlert(_ params: ShowMessageRequestParams) -> MessageActionItem? {
+ let alert = NSAlert()
- public func handleShowMessage(_ request: ShowMessageRequest, completion: @escaping (AnyJSONRPCResponse) -> Void) {
- onShowMessage.send((request, completion))
+ alert.messageText = "GitHub Copilot"
+ alert.informativeText = params.message
+ alert.alertStyle = params.type == .info ? .informational : .warning
+
+ let actions = params.actions ?? []
+ for item in actions {
+ alert.addButton(withTitle: item.title)
+ }
+
+ let response = alert.runModal()
+
+ // Map the button response to the corresponding action
+ // .alertFirstButtonReturn = 1000, .alertSecondButtonReturn = 1001, etc.
+ let buttonIndex = response.rawValue - NSApplication.ModalResponse.alertFirstButtonReturn.rawValue
+
+ guard buttonIndex >= 0 && buttonIndex < actions.count else {
+ return nil
+ }
+
+ return actions[buttonIndex]
}
}
diff --git a/Tool/Sources/GitHubCopilotService/Conversation/WatchedFilesHandler.swift b/Tool/Sources/GitHubCopilotService/Conversation/WatchedFilesHandler.swift
index f450935e..73069562 100644
--- a/Tool/Sources/GitHubCopilotService/Conversation/WatchedFilesHandler.swift
+++ b/Tool/Sources/GitHubCopilotService/Conversation/WatchedFilesHandler.swift
@@ -29,26 +29,40 @@ public final class WatchedFilesHandlerImpl: WatchedFilesHandler {
let fileUris = files.prefix(10000).map { $0.url.absoluteString } // Set max number of indexing file to 10000
let batchSize = BatchingFileChangeWatcher.maxEventPublishSize
- /// only `batchSize`(100) files to complete this event for setup watching workspace in CLS side
- let jsonResult: JSONValue = .array(fileUris.prefix(batchSize).map { .hash(["uri": .string($0)]) })
- let jsonValue: JSONValue = .hash(["files": jsonResult])
-
- completion(AnyJSONRPCResponse(id: request.id, result: jsonValue))
Task {
- if fileUris.count > batchSize {
- for startIndex in stride(from: batchSize, to: fileUris.count, by: batchSize) {
+ var sentCount = 0
+ if params.partialResultToken != nil && fileUris.count > batchSize {
+ for startIndex in stride(from: 0, to: fileUris.count, by: batchSize) {
let endIndex = min(startIndex + batchSize, fileUris.count)
- let batch = Array(fileUris[startIndex.. ProgressParams? {
+ let copilotProgress = CopilotProgressParams(token: token, value: value)
+
+ if let jsonData = try? JSONEncoder().encode(copilotProgress),
+ let progressParams = try? JSONDecoder().decode(ProgressParams.self, from: jsonData) {
+ return progressParams
+ }
+ return nil
+ }
+}
diff --git a/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift
index 119278ee..b1f89d95 100644
--- a/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift
+++ b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift
@@ -42,7 +42,7 @@ public final class GitHubCopilotExtension: BuiltinExtension {
public func workspaceDidClose(_: WorkspaceInfo) {}
- public func workspace(_ workspace: WorkspaceInfo, didOpenDocumentAt documentURL: URL) {
+ public func workspace(_ workspace: WorkspaceInfo, didOpenDocumentAt documentURL: URL) async {
guard isLanguageServerInUse else { return }
// check if file size is larger than 15MB, if so, return immediately
if let attrs = try? FileManager.default
@@ -51,21 +51,19 @@ public final class GitHubCopilotExtension: BuiltinExtension {
fileSize > 15 * 1024 * 1024
{ return }
- Task {
- let content: String
- do {
- content = try String(contentsOf: documentURL, encoding: .utf8)
- } catch {
- Logger.extension.info("Failed to read \(documentURL.lastPathComponent): \(error)")
- return
- }
-
- do {
- guard let service = await serviceLocator.getService(from: workspace) else { return }
- try await service.notifyOpenTextDocument(fileURL: documentURL, content: content)
- } catch {
- Logger.gitHubCopilot.info(error.localizedDescription)
- }
+ let content: String
+ do {
+ content = try String(contentsOf: documentURL, encoding: .utf8)
+ } catch {
+ Logger.extension.info("Failed to read \(documentURL.lastPathComponent): \(error)")
+ return
+ }
+
+ do {
+ guard let service = await serviceLocator.getService(from: workspace) else { return }
+ try await service.notifyOpenTextDocument(fileURL: documentURL, content: content)
+ } catch {
+ Logger.gitHubCopilot.info(error.localizedDescription)
}
}
@@ -96,8 +94,9 @@ public final class GitHubCopilotExtension: BuiltinExtension {
public func workspace(
_ workspace: WorkspaceInfo,
didUpdateDocumentAt documentURL: URL,
- content: String?
- ) {
+ content: String?,
+ contentChanges: [TextDocumentContentChangeEvent]? = nil
+ ) async {
guard isLanguageServerInUse else { return }
// check if file size is larger than 15MB, if so, return immediately
if let attrs = try? FileManager.default
@@ -106,27 +105,26 @@ public final class GitHubCopilotExtension: BuiltinExtension {
fileSize > 15 * 1024 * 1024
{ return }
- Task {
- guard let content else { return }
- guard let service = await serviceLocator.getService(from: workspace) else { return }
- do {
- try await service.notifyChangeTextDocument(
- fileURL: documentURL,
- content: content,
- version: 0
- )
- } catch let error as ServerError {
- switch error {
- case .serverError(-32602, _, _): // parameter incorrect
- Logger.gitHubCopilot.error(error.localizedDescription)
- // Reopen document if it's not found in the language server
- self.workspace(workspace, didOpenDocumentAt: documentURL)
- default:
- Logger.gitHubCopilot.info(error.localizedDescription)
- }
- } catch {
+ guard let content else { return }
+ guard let service = await serviceLocator.getService(from: workspace) else { return }
+ do {
+ try await service.notifyChangeTextDocument(
+ fileURL: documentURL,
+ content: content,
+ version: 0,
+ contentChanges: contentChanges
+ )
+ } catch let error as ServerError {
+ switch error {
+ case .serverError(-32602, _, _): // parameter incorrect
+ Logger.gitHubCopilot.error(error.localizedDescription)
+ // Reopen document if it's not found in the language server
+ await self.workspace(workspace, didOpenDocumentAt: documentURL)
+ default:
Logger.gitHubCopilot.info(error.localizedDescription)
}
+ } catch {
+ Logger.gitHubCopilot.info(error.localizedDescription)
}
}
diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/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/CopilotLanguageModelToolManager.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLanguageModelToolManager.swift
index 554a4bfe..2c530455 100644
--- a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLanguageModelToolManager.swift
+++ b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLanguageModelToolManager.swift
@@ -5,6 +5,8 @@ import Logger
public extension Notification.Name {
static let gitHubCopilotToolsDidChange = Notification
.Name("com.github.CopilotForXcode.CopilotToolsDidChange")
+ static let gitHubCopilotCustomAgentToolsDidChange = Notification
+ .Name("com.github.CopilotForXcode.CustomAgentToolsDidChange")
}
public class CopilotLanguageModelToolManager {
@@ -24,8 +26,8 @@ public class CopilotLanguageModelToolManager {
}
// Map previous and new by name for merging.
- let previousByName = Dictionary(uniqueKeysWithValues: previous.map { ($0.name, $0) })
- let incomingByName = Dictionary(uniqueKeysWithValues: tools.map { ($0.name, $0) })
+ let previousByName = Dictionary(previous.map { ($0.name, $0) }) { first, _ in first }
+ let incomingByName = Dictionary(tools.map { ($0.name, $0) }) { first, _ in first }
var merged: [LanguageModelTool] = []
diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift
index 29e33d35..b28139ca 100644
--- a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift
+++ b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift
@@ -1,4 +1,5 @@
import Combine
+import ConversationServiceProvider
import Foundation
import JSONRPC
import LanguageClient
@@ -29,12 +30,37 @@ public enum ServerError: LocalizedError {
message: error.message,
data: error.data)
}
+
+ static func decodingError(_ error: DecodingError) -> ServerError {
+ let message: String
+
+ switch error {
+ case .typeMismatch(let type, let context):
+ message = "Type mismatch: Expected \(type). \(context.debugDescription)"
+
+ case .valueNotFound(let type, let context):
+ message = "Value not found: Expected \(type). \(context.debugDescription)"
+
+ case .keyNotFound(let key, let context):
+ message = "Key '\(key.stringValue)' not found. \(context.debugDescription)"
+
+ case .dataCorrupted(let context):
+ message = "Data corrupted: \(context.debugDescription)"
+
+ @unknown default:
+ message = error.localizedDescription
+ }
+
+ return ServerError.serverError(code: -32700, message: message, data: nil)
+ }
static func convertToServerError(error: any Error) -> ServerError {
if let serverError = error as? ServerError {
return serverError
} else if let jsonRPCError = error as? AnyJSONRPCResponseError {
return responseError(jsonRPCError)
+ } else if let decodeError = error as? DecodingError {
+ return decodingError(decodeError)
}
return .unknownError(error)
@@ -114,7 +140,7 @@ class CopilotLocalProcessServer {
return
}
- if request.method == "getCompletionsCycling" {
+ if request.method == "getCompletionsCycling" || request.method == "textDocument/copilotInlineEdit" {
Task { @MainActor [weak self] in
self?.ongoingCompletionRequestIDs.append(request.id)
}
@@ -196,6 +222,24 @@ class CopilotLocalProcessServer {
case "copilot/mcpRuntimeLogs":
notificationPublisher.send(anyNotification)
return true
+ case "policy/didChange":
+ notificationPublisher.send(anyNotification)
+ return true
+ case "$/copilot/compressionStarted":
+ notificationPublisher.send(anyNotification)
+ return true
+ case "$/copilot/compressionCompleted":
+ notificationPublisher.send(anyNotification)
+ return true
+ case "$/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
@@ -237,13 +281,17 @@ extension CopilotLocalProcessServer: ServerConnection {
let method = notif.method.rawValue
- switch notif {
- case .copilotDidChangeWatchedFiles(let params):
- do {
+ do {
+ switch notif {
+ case .copilotDidChangeWatchedFiles(let params):
+ try await server.sendNotification(params, method: method)
+ case .clientProtocolProgress(let params):
+ try await server.sendNotification(params, method: method)
+ case .textDocumentDidShowInlineEdit(let params):
try await server.sendNotification(params, method: method)
- } catch {
- throw ServerError.unableToSendNotification(error)
}
+ } catch {
+ throw ServerError.unableToSendNotification(error)
}
}
@@ -321,14 +369,22 @@ public struct CopilotDidChangeWatchedFilesParams: Codable, Hashable {
public enum CopilotClientNotification {
public enum Method: String {
case workspaceDidChangeWatchedFiles = "workspace/didChangeWatchedFiles"
+ case protocolProgress = "$/progress"
+ case textDocumentDidShowInlineEdit = "textDocument/didShowInlineEdit"
}
case copilotDidChangeWatchedFiles(CopilotDidChangeWatchedFilesParams)
+ case clientProtocolProgress(ProgressParams)
+ case textDocumentDidShowInlineEdit(TextDocumentDidShowInlineEditParams)
public var method: Method {
switch self {
case .copilotDidChangeWatchedFiles:
return .workspaceDidChangeWatchedFiles
+ case .clientProtocolProgress:
+ return .protocolProgress
+ case .textDocumentDidShowInlineEdit:
+ return .textDocumentDidShowInlineEdit
}
}
}
diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotMCPToolManager.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotMCPToolManager.swift
index a2baecbc..9533e367 100644
--- a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotMCPToolManager.swift
+++ b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotMCPToolManager.swift
@@ -15,7 +15,12 @@ public class CopilotMCPToolManager {
availableMCPServerTools = sortedMCPServerTools
DispatchQueue.main.async {
Logger.client.info("Notify about MCP tools change: \(getToolsSummary())")
- DistributedNotificationCenter.default().post(name: .gitHubCopilotMCPToolsDidChange, object: nil)
+ DistributedNotificationCenter.default().postNotificationName(
+ .gitHubCopilotMCPToolsDidChange,
+ object: nil,
+ userInfo: nil,
+ deliverImmediately: true
+ )
}
}
@@ -23,7 +28,7 @@ public class CopilotMCPToolManager {
var summary = ""
guard let tools = availableMCPServerTools else { return summary }
for server in tools {
- summary += "Server: \(server.name) with \(server.tools.count) tools (\(server.tools.filter { $0._status == .enabled }.count) enabled, \(server.tools.filter { $0._status == .disabled }.count) disabled). "
+ summary += "Server: \(server.name) \(server.status), with \(server.tools.count) tools (\(server.tools.filter { $0._status == .enabled }.count) enabled, \(server.tools.filter { $0._status == .disabled }.count) disabled). "
}
return summary
@@ -45,7 +50,12 @@ public class CopilotMCPToolManager {
public static func clearMCPTools() {
availableMCPServerTools = []
DispatchQueue.main.async {
- DistributedNotificationCenter.default().post(name: .gitHubCopilotMCPToolsDidChange, object: nil)
+ DistributedNotificationCenter.default().postNotificationName(
+ .gitHubCopilotMCPToolsDidChange,
+ object: nil,
+ userInfo: nil,
+ deliverImmediately: true
+ )
}
}
}
diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift
index d81d5211..284c27c7 100644
--- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift
+++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift
@@ -1,9 +1,10 @@
+import ConversationServiceProvider
import Foundation
import JSONRPC
import LanguageServerProtocol
+import Preferences
import Status
import SuggestionBasic
-import ConversationServiceProvider
struct GitHubCopilotDoc: Codable {
var source: String
@@ -80,7 +81,7 @@ public func editorConfiguration(includeMCP: Bool) -> JSONValue {
var authProvider: JSONValue? {
let enterpriseURI = UserDefaults.shared.value(for: \.gitHubCopilotEnterpriseURI)
- return .hash([ "uri": .string(enterpriseURI) ])
+ return .hash(["uri": .string(enterpriseURI)])
}
var mcp: JSONValue? {
@@ -92,6 +93,79 @@ public func editorConfiguration(includeMCP: Bool) -> JSONValue {
let instructions = UserDefaults.shared.value(for: \.globalCopilotInstructions)
return .string(instructions)
}
+
+ var agent: JSONValue? {
+ var d: [String: JSONValue] = [:]
+
+ let agentMaxToolCallingLoop = Double(UserDefaults.shared.value(for: \.agentMaxToolCallingLoop))
+ d["maxToolCallingLoop"] = .number(agentMaxToolCallingLoop)
+
+ // Auto Approval Settings
+ // Disable auto approval (yolo mode)
+ let enableAutoApproval = false
+ d["toolConfirmAutoApprove"] = .bool(enableAutoApproval)
+
+ let trustToolAnnotations = UserDefaults.shared.value(for: \.trustToolAnnotations)
+ d["trustToolAnnotations"] = .bool(trustToolAnnotations)
+
+ let autoCompress = UserDefaults.shared.value(for: \.autoCompress)
+ d["autoCompress"] = .bool(autoCompress)
+
+ let state = UserDefaults.autoApproval.value(for: \.sensitiveFilesGlobalApprovals)
+ var autoApproveList: [JSONValue] = []
+ for (key, rule) in state.rules {
+ let item: [String: JSONValue] = [
+ "pattern": .string(key),
+ "autoApprove": .bool(rule.autoApprove),
+ "description": .string(rule.description)
+ ]
+ autoApproveList.append(.hash(item))
+ }
+
+ var tools: [String: JSONValue] = [:]
+
+ if !autoApproveList.isEmpty {
+ tools["edit"] = .hash([
+ "autoApprove": .array(autoApproveList)
+ ])
+ }
+
+ let mcpGlobalApprovals = UserDefaults.autoApproval.value(for: \.mcpServersGlobalApprovals)
+ var mcpAutoApproveList: [JSONValue] = []
+
+ for (serverName, state) in mcpGlobalApprovals.servers {
+ let item: [String: JSONValue] = [
+ "serverName": .string(serverName),
+ "isServerAllowed": .bool(state.isServerAllowed),
+ "allowedTools": .array(state.allowedTools.map { .string($0) })
+ ]
+ mcpAutoApproveList.append(.hash(item))
+ }
+
+ if !mcpAutoApproveList.isEmpty {
+ tools["mcp"] = .hash([
+ "autoApprove": .array(mcpAutoApproveList)
+ ])
+ }
+
+ let terminalState = UserDefaults.autoApproval.value(for: \.terminalCommandsGlobalApprovals)
+ var terminalAutoApprove: [String: JSONValue] = [:]
+ for (command, approved) in terminalState.commands {
+ terminalAutoApprove[command] = .bool(approved)
+ }
+
+ if !terminalAutoApprove.isEmpty {
+ tools["terminal"] = .hash([
+ "autoApprove": .hash(terminalAutoApprove)
+ ])
+ }
+
+ if !tools.isEmpty {
+ d["tools"] = .hash(tools)
+ }
+
+ return .hash(d)
+ }
var d: [String: JSONValue] = [:]
if let http { d["http"] = http }
@@ -103,6 +177,7 @@ public func editorConfiguration(includeMCP: Bool) -> JSONValue {
copilot["mcp"] = mcp
}
copilot["globalCopilotInstructions"] = customInstructions
+ copilot["agent"] = agent
github["copilot"] = .hash(copilot)
d["github"] = .hash(github)
}
@@ -135,7 +210,7 @@ enum GitHubCopilotRequest {
.custom("checkStatus", .hash([:]), ClientRequest.NullHandler)
}
}
-
+
struct CheckQuota: GitHubCopilotRequestType {
typealias Response = GitHubCopilotQuotaInfo
@@ -281,6 +356,20 @@ enum GitHubCopilotRequest {
]), ClientRequest.NullHandler)
}
}
+
+ // MARK: - NES
+
+ struct CopilotInlineEdit: GitHubCopilotRequestType {
+ typealias Response = CopilotInlineEditsResponse
+
+ var params: CopilotInlineEditsParams
+
+ var request: ClientRequest {
+ let data = (try? JSONEncoder().encode(params)) ?? Data()
+ let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:])
+ return .custom("textDocument/copilotInlineEdit", dict, ClientRequest.NullHandler)
+ }
+ }
struct NotifyShown: GitHubCopilotRequestType {
struct Response: Codable {}
@@ -312,6 +401,21 @@ enum GitHubCopilotRequest {
return .custom("notifyAccepted", .hash(dict), ClientRequest.NullHandler)
}
}
+
+ struct NotifyCopilotInlineEditAccepted: GitHubCopilotRequestType {
+ typealias Response = Bool
+
+ // NES suggestion ID
+ var params: [String]
+
+ var request: ClientRequest {
+ let args: [JSONValue] = params.map { JSONValue.string($0) }
+ return .workspaceExecuteCommand(
+ .init(command: "github.copilot.didAcceptNextEditSuggestionItem", arguments: args),
+ ClientRequest.NullHandler
+ )
+ }
+ }
struct NotifyRejected: GitHubCopilotRequestType {
struct Response: Codable {}
@@ -328,7 +432,7 @@ enum GitHubCopilotRequest {
// MARK: Conversation
struct CreateConversation: GitHubCopilotRequestType {
- struct Response: Codable {}
+ typealias Response = ConversationCreateResponse
var params: ConversationCreateParams
@@ -342,7 +446,7 @@ enum GitHubCopilotRequest {
// MARK: Conversation turn
struct CreateTurn: GitHubCopilotRequestType {
- struct Response: Codable {}
+ typealias Response = ConversationCreateResponse
var params: TurnCreateParams
@@ -352,12 +456,12 @@ enum GitHubCopilotRequest {
return .custom("conversation/turn", dict, ClientRequest.NullHandler)
}
}
-
+
struct DeleteTurn: GitHubCopilotRequestType {
struct Response: Codable {}
-
+
var params: TurnDeleteParams
-
+
var request: ClientRequest {
let data = (try? JSONEncoder().encode(params)) ?? Data()
let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:])
@@ -378,12 +482,12 @@ enum GitHubCopilotRequest {
return .custom("conversation/rating", dict, ClientRequest.NullHandler)
}
}
-
+
// MARK: Conversation templates
struct GetTemplates: GitHubCopilotRequestType {
typealias Response = Array
-
+
var params: ConversationTemplatesParams
var request: ClientRequest {
@@ -393,6 +497,22 @@ enum GitHubCopilotRequest {
}
}
+ // MARK: Conversation Modes
+
+ struct GetModes: GitHubCopilotRequestType {
+ typealias Response = Array
+
+ var params: ConversationModesParams
+
+ var request: ClientRequest {
+ let data = (try? JSONEncoder().encode(params)) ?? Data()
+ let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:])
+ return .custom("conversation/modes", dict, ClientRequest.NullHandler)
+ }
+ }
+
+ // MARK: Copilot Models
+
struct CopilotModels: GitHubCopilotRequestType {
typealias Response = Array
@@ -400,12 +520,12 @@ enum GitHubCopilotRequest {
.custom("copilot/models", .hash([:]), ClientRequest.NullHandler)
}
}
-
+
// MARK: MCP Tools
-
+
struct UpdatedMCPToolsStatus: GitHubCopilotRequestType {
typealias Response = Array
-
+
var params: UpdateMCPToolsStatusParams
var request: ClientRequest {
@@ -414,35 +534,43 @@ enum GitHubCopilotRequest {
return .custom("mcp/updateToolsStatus", dict, ClientRequest.NullHandler)
}
}
-
+
// MARK: MCP Registry
-
+
struct MCPRegistryListServers: GitHubCopilotRequestType {
typealias Response = MCPRegistryServerList
-
+
var params: MCPRegistryListServersParams
-
+
var request: ClientRequest {
let data = (try? JSONEncoder().encode(params)) ?? Data()
let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:])
return .custom("mcp/registry/listServers", dict, ClientRequest.NullHandler)
}
}
-
+
struct MCPRegistryGetServer: GitHubCopilotRequestType {
typealias Response = MCPRegistryServerDetail
-
+
var params: MCPRegistryGetServerParams
-
+
var request: ClientRequest {
let data = (try? JSONEncoder().encode(params)) ?? Data()
let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:])
return .custom("mcp/registry/getServer", dict, ClientRequest.NullHandler)
}
}
-
+
+ struct MCPRegistryGetAllowlist: GitHubCopilotRequestType {
+ typealias Response = GetMCPRegistryAllowlistResult
+
+ var request: ClientRequest {
+ .custom("mcp/registry/getAllowlist", .hash([:]), ClientRequest.NullHandler)
+ }
+ }
+
// MARK: - Conversation Agents
-
+
struct GetAgents: GitHubCopilotRequestType {
typealias Response = Array
@@ -450,14 +578,14 @@ enum GitHubCopilotRequest {
.custom("conversation/agents", .hash([:]), ClientRequest.NullHandler)
}
}
-
+
// MARK: - Code Review
-
+
struct ReviewChanges: GitHubCopilotRequestType {
typealias Response = CodeReviewResult
-
+
var params: ReviewChangesParams
-
+
var request: ClientRequest {
let data = (try? JSONEncoder().encode(params)) ?? Data()
let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:])
@@ -476,7 +604,7 @@ enum GitHubCopilotRequest {
return .custom("conversation/registerTools", dict, ClientRequest.NullHandler)
}
}
-
+
struct UpdateToolsStatus: GitHubCopilotRequestType {
typealias Response = Array
@@ -502,7 +630,7 @@ enum GitHubCopilotRequest {
return .custom("conversation/copyCode", dict, ClientRequest.NullHandler)
}
}
-
+
// MARK: Telemetry
struct TelemetryException: GitHubCopilotRequestType {
@@ -516,11 +644,12 @@ enum GitHubCopilotRequest {
return .custom("telemetry/exception", dict, ClientRequest.NullHandler)
}
}
-
+
// MARK: BYOK
+
struct BYOKSaveModel: GitHubCopilotRequestType {
typealias Response = BYOKSaveModelResponse
-
+
var params: BYOKSaveModelParams
var request: ClientRequest {
@@ -529,10 +658,10 @@ enum GitHubCopilotRequest {
return .custom("copilot/byok/saveModel", dict, ClientRequest.NullHandler)
}
}
-
+
struct BYOKDeleteModel: GitHubCopilotRequestType {
typealias Response = BYOKDeleteModelResponse
-
+
var params: BYOKDeleteModelParams
var request: ClientRequest {
@@ -541,10 +670,10 @@ enum GitHubCopilotRequest {
return .custom("copilot/byok/deleteModel", dict, ClientRequest.NullHandler)
}
}
-
+
struct BYOKListModels: GitHubCopilotRequestType {
typealias Response = BYOKListModelsResponse
-
+
var params: BYOKListModelsParams
var request: ClientRequest {
@@ -553,10 +682,10 @@ enum GitHubCopilotRequest {
return .custom("copilot/byok/listModels", dict, ClientRequest.NullHandler)
}
}
-
+
struct BYOKSaveApiKey: GitHubCopilotRequestType {
typealias Response = BYOKSaveApiKeyResponse
-
+
var params: BYOKSaveApiKeyParams
var request: ClientRequest {
@@ -565,10 +694,10 @@ enum GitHubCopilotRequest {
return .custom("copilot/byok/saveApiKey", dict, ClientRequest.NullHandler)
}
}
-
+
struct BYOKDeleteApiKey: GitHubCopilotRequestType {
typealias Response = BYOKDeleteApiKeyResponse
-
+
var params: BYOKDeleteApiKeyParams
var request: ClientRequest {
@@ -577,10 +706,10 @@ enum GitHubCopilotRequest {
return .custom("copilot/byok/deleteApiKey", dict, ClientRequest.NullHandler)
}
}
-
+
struct BYOKListApiKeys: GitHubCopilotRequestType {
typealias Response = BYOKListApiKeysResponse
-
+
var params: BYOKListApiKeysParams
var request: ClientRequest {
@@ -589,14 +718,27 @@ 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
public enum GitHubCopilotNotification {
-
public struct StatusNotification: Codable {
- public enum StatusKind : String, Codable {
+ public enum StatusKind: String, Codable {
case normal = "Normal"
case error = "Error"
case warning = "Warning"
@@ -605,13 +747,13 @@ public enum GitHubCopilotNotification {
public var clsStatus: CLSStatus.Status {
switch self {
case .normal:
- .normal
+ .normal
case .error:
- .error
+ .error
case .warning:
- .warning
+ .warning
case .inactive:
- .inactive
+ .inactive
}
}
}
@@ -625,7 +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"
@@ -638,10 +809,9 @@ public enum GitHubCopilotNotification {
public var server: String
public var tool: String?
public var time: Double
-
+
public static func decode(fromParams params: JSONValue?) -> MCPRuntimeNotification? {
try? JSONDecoder().decode(Self.self, from: (try? JSONEncoder().encode(params)) ?? Data())
}
}
-
}
diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/Conversation.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/Conversation.swift
index 370cab55..b2906139 100644
--- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/Conversation.swift
+++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/Conversation.swift
@@ -6,105 +6,6 @@ import ConversationServiceProvider
import JSONRPC
import Logger
-public enum ConversationSource: String, Codable {
- case panel, inline
-}
-
-public struct FileReference: Codable, Equatable, Hashable {
- public var type: String = "file"
- public let uri: String
- public let position: Position?
- public let visibleRange: SuggestionBasic.CursorRange?
- public let selection: SuggestionBasic.CursorRange?
- public let openedAt: String?
- public let activeAt: String?
-}
-
-public struct DirectoryReference: Codable, Equatable, Hashable {
- public var type: String = "directory"
- public let uri: String
-}
-
-public enum Reference: Codable, Equatable, Hashable {
- case file(FileReference)
- case directory(DirectoryReference)
-
- public func encode(to encoder: Encoder) throws {
- switch self {
- case .file(let fileRef):
- try fileRef.encode(to: encoder)
- case .directory(let directoryRef):
- try directoryRef.encode(to: encoder)
- }
- }
-
- private enum CodingKeys: String, CodingKey {
- case type
- }
-
- public init(from decoder: Decoder) throws {
- let container = try decoder.container(keyedBy: CodingKeys.self)
- let type = try container.decode(String.self, forKey: .type)
-
- switch type {
- case "file":
- let fileRef = try FileReference(from: decoder)
- self = .file(fileRef)
- case "directory":
- let directoryRef = try DirectoryReference(from: decoder)
- self = .directory(directoryRef)
- default:
- throw DecodingError.dataCorrupted(
- DecodingError.Context(
- codingPath: decoder.codingPath,
- debugDescription: "Unknown reference type: \(type)"
- )
- )
- }
- }
-
- public static func from(_ ref: ConversationAttachedReference) -> Reference {
- switch ref {
- case .file(let fileRef):
- return .file(
- .init(
- uri: fileRef.url.absoluteString,
- position: nil,
- visibleRange: nil,
- selection: nil,
- openedAt: nil,
- activeAt: nil
- )
- )
- case .directory(let directoryRef):
- return .directory(.init(uri: directoryRef.url.absoluteString))
- }
- }
-}
-
-struct ConversationCreateParams: Codable {
- var workDoneToken: String
- var turns: [TurnSchema]
- var capabilities: Capabilities
- var textDocument: Doc?
- var references: [Reference]?
- var computeSuggestions: Bool?
- var source: ConversationSource?
- var workspaceFolder: String?
- var workspaceFolders: [WorkspaceFolder]?
- var ignoredSkills: [String]?
- var model: String?
- var modelProviderName: String?
- var chatMode: String?
- var needToolCallConfirmation: Bool?
- var userLanguage: String?
-
- struct Capabilities: Codable {
- var skills: [String]
- var allSkills: Bool?
- }
-}
-
// MARK: Conversation Progress
public enum ConversationProgressKind: String, Codable {
@@ -121,6 +22,7 @@ public struct ConversationProgressBegin: BaseConversationProgress {
public let kind: ConversationProgressKind
public let conversationId: String
public let turnId: String
+ public let parentTurnId: String?
}
public struct ConversationProgressReport: BaseConversationProgress {
@@ -132,6 +34,9 @@ public struct ConversationProgressReport: BaseConversationProgress {
public let references: [FileReference]?
public let steps: [ConversationProgressStep]?
public let editAgentRounds: [AgentRound]?
+ public let parentTurnId: String?
+ public let thinking: Thinking?
+ public let contextSize: ContextSizeInfo?
}
public struct ConversationProgressEnd: BaseConversationProgress {
@@ -189,6 +94,8 @@ struct ConversationTemplatesParams: Codable {
var workspaceFolders: [WorkspaceFolder]?
}
+typealias ConversationModesParams = ConversationTemplatesParams
+
// MARK: Conversation turn
struct TurnCreateParams: Codable {
var workDoneToken: String
@@ -200,9 +107,11 @@ struct TurnCreateParams: Codable {
var references: [Reference]?
var model: String?
var modelProviderName: String?
+ var modelInfo: ConversationModelInfo?
var workspaceFolder: String?
var workspaceFolders: [WorkspaceFolder]?
var chatMode: String?
+ var customChatModeId: String?
var needToolCallConfirmation: Bool?
}
@@ -242,6 +151,7 @@ public struct WatchedFilesParams: Codable {
public var workspaceFolder: WorkspaceFolder
public var excludeGitignoredFiles: Bool
public var excludeIDEIgnoredFiles: Bool
+ public var partialResultToken: ProgressToken?
}
public typealias WatchedFilesRequest = JSONRPCRequest
diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCP.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCP.swift
index e7d37610..17792a88 100644
--- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCP.swift
+++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCP.swift
@@ -7,6 +7,7 @@ public enum MCPServerStatus: String, Codable, Equatable, Hashable {
case running = "running"
case stopped = "stopped"
case error = "error"
+ case blocked = "blocked"
}
public struct InputSchema: Codable, Equatable, Hashable {
@@ -109,12 +110,20 @@ public struct MCPServerToolsCollection: Codable, Equatable, Hashable {
public let status: MCPServerStatus
public let tools: [MCPTool]
public let error: String?
+ public let registryInfo: String?
- public init(name: String, status: MCPServerStatus, tools: [MCPTool], error: String? = nil) {
+ public init(
+ name: String,
+ status: MCPServerStatus,
+ tools: [MCPTool],
+ error: String? = nil,
+ registryInfo: String? = nil
+ ) {
self.name = name
self.status = status
self.tools = tools
self.error = error
+ self.registryInfo = registryInfo
}
}
@@ -147,22 +156,78 @@ public struct UpdateMCPToolsStatusServerCollection: Codable, Hashable {
}
public struct UpdateMCPToolsStatusParams: Codable, Hashable {
+ public var chatModeKind: ChatMode?
+ public var customChatModeId: String?
+ public var workspaceFolders: [WorkspaceFolder]?
public var servers: [UpdateMCPToolsStatusServerCollection]
-
- public init(servers: [UpdateMCPToolsStatusServerCollection]) {
+
+ public init(
+ chatModeKind: ChatMode? = nil,
+ customChatModeId: String? = nil,
+ workspaceFolders: [WorkspaceFolder]? = nil,
+ servers: [UpdateMCPToolsStatusServerCollection]
+ ) {
+ self.chatModeKind = chatModeKind
+ self.customChatModeId = customChatModeId
+ self.workspaceFolders = workspaceFolders
self.servers = servers
}
}
public typealias CopilotMCPToolsRequest = JSONRPCRequest
-public struct MCPOAuthRequestParams: Codable, Hashable {
- public var mcpServer: String
- public var authLabel: String
+public struct DynamicOAuthParams: Codable, Hashable {
+ public let title: String
+ public let header: String?
+ public let detail: String
+ public let inputs: [DynamicOAuthInput]
+
+ public init(
+ title: String,
+ header: String?,
+ detail: String,
+ inputs: [DynamicOAuthInput]
+ ) {
+ self.title = title
+ self.header = header
+ self.detail = detail
+ self.inputs = inputs
+ }
}
-public struct MCPOAuthResponse: Codable, Hashable {
- public var confirm: Bool
+public struct DynamicOAuthInput: Codable, Hashable {
+ public let title: String
+ public let value: String
+ public let description: String
+ public let placeholder: String
+ public let required: Bool
+
+ public init(
+ title: String,
+ value: String,
+ description: String,
+ placeholder: String,
+ required: Bool
+ ) {
+ self.title = title
+ self.value = value
+ self.description = description
+ self.placeholder = placeholder
+ self.required = required
+ }
}
-public typealias MCPOAuthRequest = JSONRPCRequest
+public typealias DynamicOAuthRequest = JSONRPCRequest
+
+public struct DynamicOAuthResponse: Codable, Hashable {
+ public let clientId: String
+ public let clientSecret: String
+
+ public init(
+ clientId: String,
+ clientSecret: String
+ ) {
+ self.clientId = clientId
+ self.clientSecret = clientSecret
+ }
+}
diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCPRegistry.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCPRegistry.swift
index dc891647..fd1c8bf6 100644
--- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCPRegistry.swift
+++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCPRegistry.swift
@@ -3,30 +3,17 @@ import JSONRPC
import ConversationServiceProvider
/// Schema definitions for MCP Registry API based on the OpenAPI spec:
-/// https://github.com/modelcontextprotocol/registry/blob/main/docs/reference/api/openapi.yaml
+/// https://github.com/modelcontextprotocol/registry/blob/v1.3.3/docs/reference/api/openapi.yaml
-// MARK: - Repository
-
-public struct Repository: Codable {
- public let url: String
- public let source: String
- public let id: String?
- public let subfolder: String?
-
- enum CodingKeys: String, CodingKey {
- case url, source, id, subfolder
- }
-}
+// MARK: - Inputs
-// MARK: - Server Status
-
-public enum ServerStatus: String, Codable {
- case active
- case deprecated
+public enum ArgumentFormat: String, Codable {
+ case string
+ case number
+ case boolean
+ case filepath
}
-// MARK: - Base Input Protocol
-
public protocol InputProtocol: Codable {
var description: String? { get }
var isRequired: Bool? { get }
@@ -34,11 +21,10 @@ public protocol InputProtocol: Codable {
var value: String? { get }
var isSecret: Bool? { get }
var defaultValue: String? { get }
+ var placeholder: String? { get }
var choices: [String]? { get }
}
-// MARK: - Input (base type)
-
public struct Input: InputProtocol {
public let description: String?
public let isRequired: Bool?
@@ -46,21 +32,15 @@ public struct Input: InputProtocol {
public let value: String?
public let isSecret: Bool?
public let defaultValue: String?
+ public let placeholder: String?
public let choices: [String]?
enum CodingKeys: String, CodingKey {
- case description
- case isRequired = "is_required"
- case format
- case value
- case isSecret = "is_secret"
+ case description, isRequired, format, value, isSecret, placeholder, choices
case defaultValue = "default"
- case choices
}
}
-// MARK: - Input with Variables
-
public struct InputWithVariables: InputProtocol {
public let description: String?
public let isRequired: Bool?
@@ -68,47 +48,95 @@ public struct InputWithVariables: InputProtocol {
public let value: String?
public let isSecret: Bool?
public let defaultValue: String?
+ public let placeholder: String?
public let choices: [String]?
public let variables: [String: Input]?
enum CodingKeys: String, CodingKey {
- case description
- case isRequired = "is_required"
- case format
- case value
- case isSecret = "is_secret"
+ case description, isRequired, format, value, isSecret, placeholder, choices, variables
case defaultValue = "default"
- case choices
- case variables
}
}
-// MARK: - Argument Format
+public struct KeyValueInput: InputProtocol, Hashable {
+ public let name: String
+ public let description: String?
+ public let isRequired: Bool?
+ public let format: ArgumentFormat?
+ public let value: String?
+ public let isSecret: Bool?
+ public let defaultValue: String?
+ public let placeholder: String?
+ public let choices: [String]?
+ public let variables: [String: Input]?
+
+ public init(
+ name: String,
+ description: String?,
+ isRequired: Bool?,
+ format: ArgumentFormat?,
+ value: String?,
+ isSecret: Bool?,
+ defaultValue: String?,
+ placeholder: String?,
+ choices: [String]?,
+ variables: [String : Input]?
+ ) {
+ self.name = name
+ self.description = description
+ self.isRequired = isRequired
+ self.format = format
+ self.value = value
+ self.isSecret = isSecret
+ self.defaultValue = defaultValue
+ self.placeholder = placeholder
+ self.choices = choices
+ self.variables = variables
+ }
-public enum ArgumentFormat: String, Codable {
- case string
- case number
- case boolean
- case filepath
+ enum CodingKeys: String, CodingKey {
+ case name, description, isRequired, format, value, isSecret, placeholder, choices, variables
+ case defaultValue = "default"
+ }
+
+ public func hash(into hasher: inout Hasher) {
+ hasher.combine(name)
+ hasher.combine(description)
+ hasher.combine(isRequired)
+ hasher.combine(format)
+ hasher.combine(value)
+ hasher.combine(isSecret)
+ hasher.combine(defaultValue)
+ hasher.combine(placeholder)
+ hasher.combine(choices)
+ }
+
+ public static func == (lhs: KeyValueInput, rhs: KeyValueInput) -> Bool {
+ lhs.name == rhs.name &&
+ lhs.description == rhs.description &&
+ lhs.isRequired == rhs.isRequired &&
+ lhs.format == rhs.format &&
+ lhs.value == rhs.value &&
+ lhs.isSecret == rhs.isSecret &&
+ lhs.defaultValue == rhs.defaultValue &&
+ lhs.placeholder == rhs.placeholder &&
+ lhs.choices == rhs.choices
+ }
}
-// MARK: - Argument Type
+// MARK: - Arguments
public enum ArgumentType: String, Codable {
case positional
case named
}
-// MARK: - Base Argument Protocol
-
public protocol ArgumentProtocol: InputProtocol {
var type: ArgumentType { get }
var variables: [String: Input]? { get }
}
-// MARK: - Positional Argument
-
-public struct PositionalArgument: ArgumentProtocol {
+public struct PositionalArgument: ArgumentProtocol, Hashable {
public let type: ArgumentType = .positional
public let description: String?
public let isRequired: Bool?
@@ -116,24 +144,47 @@ public struct PositionalArgument: ArgumentProtocol {
public let value: String?
public let isSecret: Bool?
public let defaultValue: String?
+ public let placeholder: String?
public let choices: [String]?
public let variables: [String: Input]?
public let valueHint: String?
public let isRepeated: Bool?
enum CodingKeys: String, CodingKey {
- case type, description, format, value, choices, variables
- case isRequired = "is_required"
- case isSecret = "is_secret"
+ case type, description, isRequired, format, value, isSecret, placeholder, choices, variables, valueHint, isRepeated
case defaultValue = "default"
- case valueHint = "value_hint"
- case isRepeated = "is_repeated"
+ }
+
+ public func hash(into hasher: inout Hasher) {
+ hasher.combine(type)
+ hasher.combine(description)
+ hasher.combine(isRequired)
+ hasher.combine(format)
+ hasher.combine(value)
+ hasher.combine(isSecret)
+ hasher.combine(defaultValue)
+ hasher.combine(placeholder)
+ hasher.combine(choices)
+ hasher.combine(valueHint)
+ hasher.combine(isRepeated)
+ }
+
+ public static func == (lhs: PositionalArgument, rhs: PositionalArgument) -> Bool {
+ lhs.type == rhs.type &&
+ lhs.description == rhs.description &&
+ lhs.isRequired == rhs.isRequired &&
+ lhs.format == rhs.format &&
+ lhs.value == rhs.value &&
+ lhs.isSecret == rhs.isSecret &&
+ lhs.defaultValue == rhs.defaultValue &&
+ lhs.placeholder == rhs.placeholder &&
+ lhs.choices == rhs.choices &&
+ lhs.valueHint == rhs.valueHint &&
+ lhs.isRepeated == rhs.isRepeated
}
}
-// MARK: - Named Argument
-
-public struct NamedArgument: ArgumentProtocol {
+public struct NamedArgument: ArgumentProtocol, Hashable {
public let type: ArgumentType = .named
public let name: String
public let description: String?
@@ -142,22 +193,46 @@ public struct NamedArgument: ArgumentProtocol {
public let value: String?
public let isSecret: Bool?
public let defaultValue: String?
+ public let placeholder: String?
public let choices: [String]?
public let variables: [String: Input]?
public let isRepeated: Bool?
enum CodingKeys: String, CodingKey {
- case type, name, description, format, value, choices, variables
- case isRequired = "is_required"
- case isSecret = "is_secret"
+ case type, name, description, isRequired, format, value, isSecret, placeholder, choices, variables, isRepeated
case defaultValue = "default"
- case isRepeated = "is_repeated"
}
-}
-// MARK: - Argument Enum
+ public func hash(into hasher: inout Hasher) {
+ hasher.combine(type)
+ hasher.combine(name)
+ hasher.combine(description)
+ hasher.combine(isRequired)
+ hasher.combine(format)
+ hasher.combine(value)
+ hasher.combine(isSecret)
+ hasher.combine(defaultValue)
+ hasher.combine(placeholder)
+ hasher.combine(choices)
+ hasher.combine(isRepeated)
+ }
+
+ public static func == (lhs: NamedArgument, rhs: NamedArgument) -> Bool {
+ lhs.type == rhs.type &&
+ lhs.name == rhs.name &&
+ lhs.description == rhs.description &&
+ lhs.isRequired == rhs.isRequired &&
+ lhs.format == rhs.format &&
+ lhs.value == rhs.value &&
+ lhs.isSecret == rhs.isSecret &&
+ lhs.defaultValue == rhs.defaultValue &&
+ lhs.placeholder == rhs.placeholder &&
+ lhs.choices == rhs.choices &&
+ lhs.isRepeated == rhs.isRepeated
+ }
+}
-public enum Argument: Codable {
+public enum Argument: Codable, Hashable {
case positional(PositionalArgument)
case named(NamedArgument)
@@ -186,121 +261,289 @@ public enum Argument: Codable {
}
}
-// MARK: - KeyValueInput
+// MARK: - Transport
-public struct KeyValueInput: InputProtocol {
- public let name: String
- public let description: String?
- public let isRequired: Bool?
- public let format: ArgumentFormat?
- public let value: String?
- public let isSecret: Bool?
- public let defaultValue: String?
- public let choices: [String]?
- public let variables: [String: Input]?
+public enum TransportType: String, Codable {
+ case streamableHttp = "streamable-http"
+ case stdio = "stdio"
+ case sse = "sse"
+
+ public var displayText: String {
+ switch self {
+ case .streamableHttp:
+ return "Streamable HTTP"
+ case .stdio:
+ return "Stdio"
+ case .sse:
+ return "SSE"
+ }
+ }
+}
+
+public protocol TransportProtocol: Codable {
+ var type: TransportType { get }
+ var variables: [String: Input]? { get }
+}
+
+public struct StdioTransport: TransportProtocol, Hashable {
+ public let type: TransportType = .stdio
+ public let variables: [String : Input]?
enum CodingKeys: String, CodingKey {
- case name, description, format, value, choices, variables
- case isRequired = "is_required"
- case isSecret = "is_secret"
- case defaultValue = "default"
+ case type, variables
+ }
+
+ public func hash(into hasher: inout Hasher) {
+ hasher.combine(type)
+ }
+
+ public static func == (lhs: StdioTransport, rhs: StdioTransport) -> Bool {
+ lhs.type == rhs.type
+ }
+}
+
+public struct StreamableHttpTransport: TransportProtocol, Hashable {
+ public let type: TransportType = .streamableHttp
+ public let url: String
+ public let headers: [KeyValueInput]?
+ public let variables: [String : Input]?
+
+ enum CodingKeys: String, CodingKey {
+ case type, url, headers, variables
+ }
+
+ public func hash(into hasher: inout Hasher) {
+ hasher.combine(type)
+ hasher.combine(url)
+ hasher.combine(headers)
+ }
+
+ public static func == (lhs: StreamableHttpTransport, rhs: StreamableHttpTransport) -> Bool {
+ lhs.type == rhs.type &&
+ lhs.url == rhs.url &&
+ lhs.headers == rhs.headers
+ }
+}
+
+public struct SseTransport: TransportProtocol, Hashable {
+ public let type: TransportType = .sse
+ public let url: String
+ public let headers: [KeyValueInput]?
+ public let variables: [String : Input]?
+
+ enum CodingKeys: String, CodingKey {
+ case type, url, headers, variables
+ }
+
+ public func hash(into hasher: inout Hasher) {
+ hasher.combine(type)
+ hasher.combine(url)
+ hasher.combine(headers)
+ }
+
+ public static func == (lhs: SseTransport, rhs: SseTransport) -> Bool {
+ lhs.type == rhs.type &&
+ lhs.url == rhs.url &&
+ lhs.headers == rhs.headers
+ }
+}
+
+public enum Transport: Codable, Hashable {
+ case stdio(StdioTransport)
+ case streamableHTTP(StreamableHttpTransport)
+ case sse(SseTransport)
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: Discriminator.self)
+ let type = try container.decode(TransportType.self, forKey: .type)
+ switch type {
+ case .stdio:
+ self = .stdio(try StdioTransport(from: decoder))
+ case .streamableHttp:
+ self = .streamableHTTP(try StreamableHttpTransport(from: decoder))
+ case .sse:
+ self = .sse(try SseTransport(from: decoder))
+ }
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ switch self {
+ case .stdio(let arg):
+ try arg.encode(to: encoder)
+ case .streamableHTTP(let arg):
+ try arg.encode(to: encoder)
+ case .sse(let arg):
+ try arg.encode(to: encoder)
+ }
+ }
+
+ private enum Discriminator: String, CodingKey {
+ case type
+ }
+}
+
+public enum Remote: Codable, Hashable {
+ case streamableHTTP(StreamableHttpTransport)
+ case sse(SseTransport)
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: Discriminator.self)
+ let type = try container.decode(TransportType.self, forKey: .type)
+ switch type {
+ case .stdio:
+ throw DecodingError.dataCorruptedError(
+ forKey: .type,
+ in: container,
+ debugDescription: "Unexpected type: stdio for Remote"
+ )
+ case .streamableHttp:
+ self = .streamableHTTP(try StreamableHttpTransport(from: decoder))
+ case .sse:
+ self = .sse(try SseTransport(from: decoder))
+ }
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ switch self {
+ case .streamableHTTP(let arg):
+ try arg.encode(to: encoder)
+ case .sse(let arg):
+ try arg.encode(to: encoder)
+ }
+ }
+
+ private enum Discriminator: String, CodingKey {
+ case type
}
}
// MARK: - Package
-public struct Package: Codable {
- public let registryType: String?
- public let registryBaseURL: String?
- public let identifier: String?
+public struct Package: Codable, Hashable {
+ public let registryType: String
+ public let registryBaseUrl: String?
+ public let identifier: String
public let version: String?
- public let fileSHA256: String?
+ public let fileSha256: String?
public let runtimeHint: String?
+ public let transport: Transport
public let runtimeArguments: [Argument]?
public let packageArguments: [Argument]?
public let environmentVariables: [KeyValueInput]?
-
- enum CodingKeys: String, CodingKey {
- case version, identifier
- case registryType = "registry_type"
- case registryBaseURL = "registry_base_url"
- case fileSHA256 = "file_sha256"
- case runtimeHint = "runtime_hint"
- case runtimeArguments = "runtime_arguments"
- case packageArguments = "package_arguments"
- case environmentVariables = "environment_variables"
+
+ public init(
+ registryType: String,
+ registryBaseUrl: String?,
+ identifier: String,
+ version: String?,
+ fileSha256: String?,
+ runtimeHint: String?,
+ transport: Transport,
+ runtimeArguments: [Argument]?,
+ packageArguments: [Argument]?,
+ environmentVariables: [KeyValueInput]?
+ ) {
+ self.registryType = registryType
+ self.registryBaseUrl = registryBaseUrl
+ self.identifier = identifier
+ self.version = version
+ self.fileSha256 = fileSha256
+ self.runtimeHint = runtimeHint
+ self.transport = transport
+ self.runtimeArguments = runtimeArguments
+ self.packageArguments = packageArguments
+ self.environmentVariables = environmentVariables
}
}
-// MARK: - Transport Type
+// MARK: - Icons
-public enum TransportType: String, Codable {
- case streamable = "streamable"
- case streamableHttp = "streamable-http"
- case sse = "sse"
+public enum IconMimeType: String, Codable {
+ case png = "image/png"
+ case jpeg = "image/jpeg"
+ case jpg = "image/jpg"
+ case svg = "image/svg+xml"
+ case webp = "image/webp"
+}
+
+public enum IconTheme: String, Codable {
+ case light, dark
+}
+
+public struct Icon: Codable, Hashable {
+ public let src: String
+ public let mimeType: IconMimeType?
+ public let sizes: [String]?
+ public let theme: IconTheme?
}
-// MARK: - Remote
+// MARK: - Repository
-public struct Remote: Codable {
- public let transportType: TransportType
+public struct Repository: Codable {
public let url: String
- public let headers: [KeyValueInput]?
+ public let source: String
+ public let id: String?
+ public let subfolder: String?
+
+ public init(url: String, source: String, id: String?, subfolder: String?) {
+ self.url = url
+ self.source = source
+ self.id = id
+ self.subfolder = subfolder
+ }
enum CodingKeys: String, CodingKey {
- case url, headers
- case transportType = "type"
+ case url, source, id, subfolder
}
}
-// MARK: - Publisher Provided Meta
-
-public struct PublisherProvidedMeta: Codable {
- public let tool: String?
- public let version: String?
- public let buildInfo: BuildInfo?
- private let additionalProperties: [String: AnyCodable]?
+// MARK: - Meta
- public struct BuildInfo: Codable {
- public let commit: String?
- public let timestamp: String?
- public let pipelineID: String?
+public enum ServerStatus: String, Codable {
+ case active
+ case deprecated
+ case deleted
+}
- enum CodingKeys: String, CodingKey {
- case commit, timestamp
- case pipelineID = "pipeline_id"
- }
+public struct OfficialMeta: Codable {
+ public let status: ServerStatus?
+ public let publishedAt: String?
+ public let updatedAt: String?
+ public let isLatest: Bool?
+
+ public init(
+ status: ServerStatus? = nil,
+ publishedAt: String? = nil,
+ updatedAt: String? = nil,
+ isLatest: Bool? = nil
+ ) {
+ self.status = status
+ self.publishedAt = publishedAt
+ self.updatedAt = updatedAt
+ self.isLatest = isLatest
}
+}
- enum CodingKeys: String, CodingKey {
- case tool, version
- case buildInfo = "build_info"
+public struct PublisherProvidedMeta: Codable {
+ private let additionalProperties: [String: AnyCodable]?
+
+ public init(
+ additionalProperties: [String: AnyCodable]? = nil
+ ) {
+ self.additionalProperties = additionalProperties
}
public init(from decoder: Decoder) throws {
- let container = try decoder.container(keyedBy: CodingKeys.self)
- tool = try container.decodeIfPresent(String.self, forKey: .tool)
- version = try container.decodeIfPresent(String.self, forKey: .version)
- buildInfo = try container.decodeIfPresent(BuildInfo.self, forKey: .buildInfo)
-
- // Capture additional properties
let allKeys = try decoder.container(keyedBy: AnyCodingKey.self)
var extras: [String: AnyCodable] = [:]
for key in allKeys.allKeys {
- if !["tool", "version", "build_info"].contains(key.stringValue) {
- extras[key.stringValue] = try allKeys.decode(AnyCodable.self, forKey: key)
- }
+ extras[key.stringValue] = try allKeys.decode(AnyCodable.self, forKey: key)
}
additionalProperties = extras.isEmpty ? nil : extras
}
public func encode(to encoder: Encoder) throws {
- var container = encoder.container(keyedBy: CodingKeys.self)
- try container.encodeIfPresent(tool, forKey: .tool)
- try container.encodeIfPresent(version, forKey: .version)
- try container.encodeIfPresent(buildInfo, forKey: .buildInfo)
-
if let additionalProperties = additionalProperties {
var dynamicContainer = encoder.container(keyedBy: AnyCodingKey.self)
for (key, value) in additionalProperties {
@@ -310,46 +553,43 @@ public struct PublisherProvidedMeta: Codable {
}
}
-// MARK: - Official Meta
-
-public struct OfficialMeta: Codable {
- public let id: String
- public let publishedAt: String
- public let updatedAt: String
- public let isLatest: Bool
+public struct MCPRegistryExtensionMeta: Codable {
+ public let publisherProvided: PublisherProvidedMeta?
enum CodingKeys: String, CodingKey {
- case id
- case publishedAt = "published_at"
- case updatedAt = "updated_at"
- case isLatest = "is_latest"
+ case publisherProvided = "io.modelcontextprotocol.registry/publisher-provided"
}
-}
-// MARK: - Server Meta
+ public init(publisherProvided: PublisherProvidedMeta?) {
+ self.publisherProvided = publisherProvided
+ }
+}
public struct ServerMeta: Codable {
- public let publisherProvided: PublisherProvidedMeta?
public let official: OfficialMeta?
private let additionalProperties: [String: AnyCodable]?
enum CodingKeys: String, CodingKey {
- case publisherProvided = "io.modelcontextprotocol.registry/publisher-provided"
case official = "io.modelcontextprotocol.registry/official"
}
+
+ public init(
+ official: OfficialMeta? = nil,
+ additionalProperties: [String: AnyCodable]? = nil
+ ) {
+ self.official = official
+ self.additionalProperties = additionalProperties
+ }
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
- publisherProvided = try container.decodeIfPresent(PublisherProvidedMeta.self, forKey: .publisherProvided)
- official = try container.decodeIfPresent(OfficialMeta.self, forKey: .official)
+ official = try container.decode(OfficialMeta.self, forKey: .official)
- // Capture additional properties
let allKeys = try decoder.container(keyedBy: AnyCodingKey.self)
var extras: [String: AnyCodable] = [:]
-
- let knownKeys = ["io.modelcontextprotocol.registry/publisher-provided", "io.modelcontextprotocol.registry/official"]
+
for key in allKeys.allKeys {
- if !knownKeys.contains(key.stringValue) {
+ if key.stringValue != "io.modelcontextprotocol.registry/official" {
extras[key.stringValue] = try allKeys.decode(AnyCodable.self, forKey: key)
}
}
@@ -358,7 +598,6 @@ public struct ServerMeta: Codable {
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
- try container.encodeIfPresent(publisherProvided, forKey: .publisherProvided)
try container.encodeIfPresent(official, forKey: .official)
if let additionalProperties = additionalProperties {
@@ -370,79 +609,132 @@ public struct ServerMeta: Codable {
}
}
-// MARK: - Dynamic Coding Key Helper
-
-private struct AnyCodingKey: CodingKey {
- let stringValue: String
- let intValue: Int?
-
- init?(stringValue: String) {
- self.stringValue = stringValue
- self.intValue = nil
- }
-
- init?(intValue: Int) {
- self.stringValue = String(intValue)
- self.intValue = intValue
- }
-}
-
-// MARK: - Server Detail
+// MARK: - Servers
public struct MCPRegistryServerDetail: Codable {
public let name: String
public let description: String
- public let status: ServerStatus?
+ public let title: String?
public let repository: Repository?
public let version: String
- public let websiteURL: String?
- public let createdAt: String?
- public let updatedAt: String?
+ public let websiteUrl: String?
+ public let icons: [Icon]?
public let schemaURL: String?
public let packages: [Package]?
public let remotes: [Remote]?
- public let meta: ServerMeta?
+ public let meta: MCPRegistryExtensionMeta?
enum CodingKeys: String, CodingKey {
- case name, description, status, repository, version, packages, remotes
- case websiteURL = "website_url"
- case createdAt = "created_at"
- case updatedAt = "updated_at"
+ case name, description, title, repository, version, packages, remotes, websiteUrl, icons
case schemaURL = "$schema"
case meta = "_meta"
}
+
+ public init(
+ name: String,
+ description: String,
+ title: String?,
+ repository: Repository?,
+ version: String,
+ websiteUrl: String?,
+ icons: [Icon]?,
+ schemaURL: String?,
+ packages: [Package]?,
+ remotes: [Remote]?,
+ meta: MCPRegistryExtensionMeta?
+ ) {
+ self.name = name
+ self.description = description
+ self.title = title
+ self.repository = repository
+ self.version = version
+ self.websiteUrl = websiteUrl
+ self.icons = icons
+ self.schemaURL = schemaURL
+ self.packages = packages
+ self.remotes = remotes
+ self.meta = meta
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+
+ name = try container.decode(String.self, forKey: .name)
+ description = try container.decode(String.self, forKey: .description)
+ title = try container.decodeIfPresent(String.self, forKey: .title)
+ version = try container.decode(String.self, forKey: .version)
+ websiteUrl = try container.decodeIfPresent(String.self, forKey: .websiteUrl)
+ icons = try container.decodeIfPresent([Icon].self, forKey: .icons)
+ schemaURL = try container.decodeIfPresent(String.self, forKey: .schemaURL)
+ packages = try container.decodeIfPresent([Package].self, forKey: .packages)
+ remotes = try container.decodeIfPresent([Remote].self, forKey: .remotes)
+ meta = try container.decodeIfPresent(MCPRegistryExtensionMeta.self, forKey: .meta)
+
+ // Custom handling for repository: {} β nil
+ if container.contains(.repository) {
+ // Decode raw dictionary to see if it is empty
+ let repoDict = try container.decode([String: AnyCodable].self, forKey: .repository)
+ if repoDict.isEmpty {
+ repository = nil
+ } else {
+ // Re-decode as Repository from the same key
+ repository = try container.decode(Repository.self, forKey: .repository)
+ }
+ } else {
+ repository = nil
+ }
+ }
}
-// MARK: - Server List Metadata
+public struct MCPRegistryServerResponse : Codable {
+ public let server: MCPRegistryServerDetail
+ public let meta: ServerMeta?
-public struct MCPRegistryServerListMetadata: Codable {
- public let nextCursor: String?
- public let count: Int?
+ public init(server: MCPRegistryServerDetail, meta: ServerMeta? = nil) {
+ self.server = server
+ self.meta = meta
+ }
enum CodingKeys: String, CodingKey {
- case nextCursor = "next_cursor"
- case count
+ case server
+ case meta = "_meta"
}
}
-// MARK: - Server List
+public struct MCPRegistryServerListMetadata: Codable {
+ public let nextCursor: String?
+ public let count: Int?
+}
public struct MCPRegistryServerList: Codable {
- public let servers: [MCPRegistryServerDetail]
+ public let servers: [MCPRegistryServerResponse]
public let metadata: MCPRegistryServerListMetadata?
}
-// MARK: - Request Parameters
+// MARK: - Requests
public struct MCPRegistryListServersParams: Codable {
public let baseUrl: String
public let cursor: String?
public let limit: Int?
+ public let search: String?
+ public let updatedSince: String?
+ public let version: String?
- public init(baseUrl: String, cursor: String? = nil, limit: Int? = nil) {
+ public init(
+ baseUrl: String,
+ cursor: String? = nil,
+ limit: Int?,
+ search: String? = nil,
+ updatedSince: String? = nil,
+ version: String? = nil
+ ) {
self.baseUrl = baseUrl
self.cursor = cursor
self.limit = limit
+ self.search = search
+ self.updatedSince = updatedSince
+ self.version = version
}
}
@@ -457,3 +749,20 @@ public struct MCPRegistryGetServerParams: Codable {
self.version = version
}
}
+
+// MARK: - Internal Helpers
+
+private struct AnyCodingKey: CodingKey {
+ let stringValue: String
+ let intValue: Int?
+
+ init?(stringValue: String) {
+ self.stringValue = stringValue
+ self.intValue = nil
+ }
+
+ init?(intValue: Int) {
+ self.stringValue = String(intValue)
+ self.intValue = intValue
+ }
+}
diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCPRegistryAllowlist.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCPRegistryAllowlist.swift
new file mode 100644
index 00000000..39cf074f
--- /dev/null
+++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCPRegistryAllowlist.swift
@@ -0,0 +1,83 @@
+import Foundation
+
+// MARK: - MCPRegistryOwner
+
+public struct MCPRegistryOwner: Codable, Hashable {
+ public let login: String
+ public let id: Int
+ public let type: String // "Business" (Enterprise) or "Organization"
+ public let parentLogin: String?
+ public let parentId: Int?
+
+ enum CodingKeys: String, CodingKey {
+ case login
+ case id
+ case type
+ case parentLogin = "parent_login"
+ case parentId = "parent_id"
+ }
+
+ public init(login: String, id: Int, type: String, parentLogin: String? = nil, parentId: Int? = nil) {
+ self.login = login
+ self.id = id
+ self.type = type
+ self.parentLogin = parentLogin
+ self.parentId = parentId
+ }
+}
+
+// MARK: - RegistryAccess
+
+public enum RegistryAccess: String, Codable, Hashable {
+ case registryOnly = "registry_only"
+ case allowAll = "allow_all"
+}
+
+// MARK: - McpRegistryEntry
+
+public struct MCPRegistryEntry: Codable, Hashable {
+ public let url: String
+ public let registryAccess: RegistryAccess
+ public let owner: MCPRegistryOwner
+
+ enum CodingKeys: String, CodingKey {
+ case url
+ case registryAccess = "registry_access"
+ case owner
+ }
+
+ public init(url: String, registryAccess: RegistryAccess, owner: MCPRegistryOwner) {
+ self.url = url
+ self.registryAccess = registryAccess
+ self.owner = owner
+ }
+}
+
+// MARK: - GetMCPRegistryAllowlistResult
+
+/// Result schema for getMCPRegistryAllowlist method
+public struct GetMCPRegistryAllowlistResult: Codable, Hashable {
+ public let mcpRegistries: [MCPRegistryEntry]
+
+ enum CodingKeys: String, CodingKey {
+ case mcpRegistries = "mcp_registries"
+ }
+}
+
+public struct MCPRegistryErrorData: Codable {
+ public let errorType: String
+ public let status: Int?
+ public let shouldRetry: Bool?
+
+ enum CodingKeys: String, CodingKey {
+ case errorType
+ case status
+ case shouldRetry
+ }
+
+ public init(errorType: String, status: Int? = nil, shouldRetry: Bool? = nil) {
+ self.errorType = errorType
+ self.status = status
+ self.shouldRetry = shouldRetry
+ }
+}
diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/NES.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/NES.swift
new file mode 100644
index 00000000..9d87086e
--- /dev/null
+++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/NES.swift
@@ -0,0 +1,59 @@
+import SuggestionBasic
+import LanguageServerProtocol
+
+
+public struct CopilotInlineEditsParams: Codable {
+ public let textDocument: VersionedTextDocumentIdentifier
+ public let position: CursorPosition
+}
+
+public struct CopilotInlineEdit: Codable {
+ public struct Command: Codable {
+ public let title: String
+ public let command: String
+ public let arguments: [String]
+ }
+ /**
+ * The new text for this edit.
+ */
+ public let text: String
+ /**
+ * The text document this edit applies to including the version
+ * Uses the same schema as for completions: src
+ *
+ * "textDocument": {
+ * "uri": "file:///path/to/file",
+ * "version": 0
+ * },
+ *
+ */
+ public let textDocument: VersionedTextDocumentIdentifier
+ public let range: CursorRange
+ /**
+ * Called by the client with workspace/executeCommand after accepting the next edit suggestion.
+ */
+ public let command: Command?
+}
+
+public struct CopilotInlineEditsResponse: Codable {
+ public let edits: [CopilotInlineEdit]
+}
+
+// MARK: - Notification
+
+public struct TextDocumentDidShowInlineEditParams: Codable, Hashable {
+ public struct Command: Codable, Hashable {
+ public var arguments: [String]
+ }
+
+ public struct NotificationCommandSchema: Codable, Hashable {
+ public var command: Command
+ }
+
+ public var item: NotificationCommandSchema
+
+ public static func from(id: String) -> Self {
+ .init(item: .init(command: .init(arguments: [id])))
+ }
+}
+
diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift
index 273cbb7f..b1f02fcc 100644
--- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift
+++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift
@@ -33,11 +33,23 @@ public protocol GitHubCopilotSuggestionServiceType {
indentSize: Int,
usesTabsForIndentation: Bool
) async throws -> [CodeSuggestion]
+ func getCopilotInlineEdit(
+ fileURL: URL,
+ content: String,
+ cursorPosition: CursorPosition
+ ) async throws -> [CodeSuggestion]
func notifyShown(_ completion: CodeSuggestion) async
+ func notifyCopilotInlineEditShown(_ completion: CodeSuggestion) async
func notifyAccepted(_ completion: CodeSuggestion, acceptedLength: Int?) async
+ func notifyCopilotInlineEditAccepted(_ completion: CodeSuggestion) async
func notifyRejected(_ completions: [CodeSuggestion]) async
func notifyOpenTextDocument(fileURL: URL, content: String) async throws
- func notifyChangeTextDocument(fileURL: URL, content: String, version: Int) async throws
+ func notifyChangeTextDocument(
+ fileURL: URL,
+ content: String,
+ version: Int,
+ contentChanges: [TextDocumentContentChangeEvent]?
+ ) async throws
func notifyCloseTextDocument(fileURL: URL) async throws
func notifySaveTextDocument(fileURL: URL) async throws
func cancelRequest() async
@@ -63,9 +75,11 @@ public protocol GitHubCopilotConversationServiceType {
references: [ConversationAttachedReference],
model: String?,
modelProviderName: String?,
+ reasoningEffort: String?,
turns: [TurnSchema],
agentMode: Bool,
- userLanguage: String?) async throws
+ customChatModeId: String?,
+ userLanguage: String?) async throws -> ConversationCreateResponse
func createTurn(_ message: MessageContent,
workDoneToken: String,
conversationId: String,
@@ -75,17 +89,21 @@ public protocol GitHubCopilotConversationServiceType {
references: [ConversationAttachedReference],
model: String?,
modelProviderName: String?,
+ reasoningEffort: String?,
workspaceFolder: String,
workspaceFolders: [WorkspaceFolder]?,
- agentMode: Bool) async throws
+ agentMode: Bool,
+ customChatModeId: String?) async throws -> ConversationCreateResponse
func deleteTurn(conversationId: String, turnId: String) async throws
func rateConversation(turnId: String, rating: ConversationRating) async throws
func copyCode(turnId: String, codeBlockIndex: Int, copyType: CopyKind, copiedCharacters: Int, totalCharacters: Int, copiedText: String) async throws
func cancelProgress(token: String) async
func templates(workspaceFolders: [WorkspaceFolder]?) async throws -> [ChatTemplate]
+ func modes(workspaceFolders: [WorkspaceFolder]?) async throws -> [ConversationMode]
func models() async throws -> [CopilotModel]
func registerTools(tools: [LanguageModelToolInformation]) async throws -> [LanguageModelTool]
func updateToolsStatus(params: UpdateToolsStatusParams) async throws -> [LanguageModelTool]
+ func generateThinkingTitle(params: GenerateThinkingTitleParams) async throws -> GenerateThinkingTitleResponse
}
protocol GitHubCopilotLSP {
@@ -149,6 +167,14 @@ public enum GitHubCopilotError: Error, LocalizedError {
public extension Notification.Name {
static let gitHubCopilotShouldRefreshEditorInformation = Notification
.Name("com.github.CopilotForXcode.GitHubCopilotShouldRefreshEditorInformation")
+ static let githubCopilotAgentMaxToolCallingLoopDidChange = Notification
+ .Name("com.github.CopilotForXcode.GithubCopilotAgentMaxToolCallingLoopDidChange")
+ static let githubCopilotAgentAutoApprovalDidChange = Notification
+ .Name("com.github.CopilotForXcode.GithubCopilotAgentAutoApprovalDidChange")
+ static let githubCopilotAgentTrustToolAnnotationsDidChange = Notification
+ .Name("com.github.CopilotForXcode.GithubCopilotAgentTrustToolAnnotationsDidChange")
+ static let githubCopilotAgentAutoCompressDidChange = Notification
+ .Name("com.github.CopilotForXcode.GithubCopilotAgentAutoCompressDidChange")
}
public class GitHubCopilotBaseService {
@@ -194,6 +220,7 @@ public class GitHubCopilotBaseService {
let watchedFiles = JSONValue(
booleanLiteral: projectRootURL.path == "/" ? false : true
)
+ let enableSubagent = UserDefaults.shared.value(for: \.enableSubagent)
#if DEBUG
// Use local language server if set and available
@@ -272,7 +299,10 @@ public class GitHubCopilotBaseService {
"copilotCapabilities": [
/// The editor has support for watching files over LSP
"watchedFiles": watchedFiles,
- "didChangeFeatureFlags": true
+ "didChangeFeatureFlags": true,
+ "stateDatabase": true,
+ "subAgent": JSONValue(booleanLiteral: enableSubagent),
+ "mcpAllowlist": true,
],
"githubAppId": authAppId.map(JSONValue.string) ?? .null,
],
@@ -422,11 +452,10 @@ public final class GitHubCopilotService:
private var cancellables = Set()
private var statusWatcher: CopilotAuthStatusWatcher?
private static var services: [GitHubCopilotService] = [] // cache all alive copilot service instances
- private var isMCPInitialized = false
- private var unrestoredMcpServers: [String] = []
private var mcpRuntimeLogFileName: String = ""
private static let toolInitializationActor = ToolInitializationActor()
private var lastSentConfiguration: JSONValue?
+ private var mcpToolsContinuation: AsyncStream.Continuation?
override init(designatedServer: any GitHubCopilotLSP) {
super.init(designatedServer: designatedServer)
@@ -438,14 +467,18 @@ public final class GitHubCopilotService:
self.handleSendWorkspaceDidChangeNotifications()
+ let (stream, continuation) = AsyncStream.makeStream(of: AnyJSONRPCNotification.self)
+ self.mcpToolsContinuation = continuation
+
+ Task { [weak self] in
+ for await notification in stream {
+ await self?.handleMCPToolsNotification(notification)
+ }
+ }
+
localProcessServer?.notificationPublisher.sink(receiveValue: { [weak self] notification in
if notification.method == "copilot/mcpTools" && projectRootURL.path != "/" {
- DispatchQueue.main.async { [weak self] in
- guard let self else { return }
- Task { @MainActor in
- await self.handleMCPToolsNotification(notification)
- }
- }
+ self?.mcpToolsContinuation?.yield(notification)
}
if notification.method == "copilot/mcpRuntimeLogs" && projectRootURL.path != "/" {
@@ -464,15 +497,12 @@ public final class GitHubCopilotService:
for await event in server.eventSequence {
switch event {
case let .request(id, request):
- switch request {
- case let .custom(method, params, callback):
- if method == "copilot/mcpOAuth" && projectRootURL.path == "/" {
- continue
- }
- self.serverRequestHandler.handleRequest(.init(id: id, method: method, params: params), workspaceURL: workspaceURL, callback: callback, service: self)
- default:
- break
- }
+ self.serverRequestHandler.handleRequest(
+ id: id,
+ request,
+ workspaceURL: workspaceURL,
+ service: self
+ )
default:
break
}
@@ -517,7 +547,7 @@ public final class GitHubCopilotService:
do {
let completions = try await self
.sendRequest(GitHubCopilotRequest.InlineCompletion(doc: .init(
- textDocument: .init(uri: fileURL.absoluteString, version: 1),
+ textDocument: .init(uri: fileURL.absoluteString, version: 0),
position: cursorPosition,
formattingOptions: .init(
tabSize: tabSize,
@@ -564,36 +594,12 @@ public final class GitHubCopilotService:
}
}
- func recoverContent() async {
- try? await notifyChangeTextDocument(
- fileURL: fileURL,
- content: originalContent,
- version: 0
- )
- }
-
- // since when the language server is no longer using the passed in content to generate
- // suggestions, we will need to update the content to the file before we do any request.
- //
- // And sometimes the language server's content was not up to date and may generate
- // weird result when the cursor position exceeds the line.
let task = Task { @GitHubCopilotSuggestionActor in
- try? await notifyChangeTextDocument(
- fileURL: fileURL,
- content: content,
- version: 1
- )
-
do {
+ let maxTry: Int = 5
try Task.checkCancellation()
- return try await sendRequest()
- } catch let error as CancellationError {
- if ongoingTasks.isEmpty {
- await recoverContent()
- }
- throw error
+ return try await sendRequest(maxTry: maxTry)
} catch {
- await recoverContent()
throw error
}
}
@@ -602,21 +608,60 @@ public final class GitHubCopilotService:
return try await task.value
}
+
+ // MARK: - NES
+ @GitHubCopilotSuggestionActor
+ public func getCopilotInlineEdit(
+ fileURL: URL,
+ content: String,
+ cursorPosition: CursorPosition
+ ) async throws -> [CodeSuggestion] {
+ ongoingTasks.forEach { $0.cancel() }
+ ongoingTasks.removeAll()
+ await localProcessServer?.cancelOngoingTasks()
+
+ do {
+ let completions = try await sendRequest(
+ GitHubCopilotRequest.CopilotInlineEdit(
+ params: CopilotInlineEditsParams(
+ textDocument: .init(uri: fileURL.absoluteString, version: 0),
+ position: cursorPosition
+ )
+ ))
+ .edits
+ .compactMap { edit in
+ CodeSuggestion.init(
+ id: edit.command?.arguments.first ?? UUID().uuidString,
+ text: edit.text,
+ position: cursorPosition,
+ range: edit.range
+ )
+ }
+ return completions
+ } catch {
+ Logger.gitHubCopilot.error("Failed to get copilot inline edit: \(error.localizedDescription)")
+ throw error
+ }
+ }
@GitHubCopilotSuggestionActor
- public func createConversation(_ message: MessageContent,
- workDoneToken: String,
- workspaceFolder: String,
- workspaceFolders: [WorkspaceFolder]? = nil,
- activeDoc: Doc?,
- skills: [String],
- ignoredSkills: [String]?,
- references: [ConversationAttachedReference],
- model: String?,
- modelProviderName: String?,
- turns: [TurnSchema],
- agentMode: Bool,
- userLanguage: String?) async throws {
+ public func createConversation(
+ _ message: MessageContent,
+ workDoneToken: String,
+ workspaceFolder: String,
+ workspaceFolders: [WorkspaceFolder]? = nil,
+ activeDoc: Doc?,
+ skills: [String],
+ ignoredSkills: [String]?,
+ references: [ConversationAttachedReference],
+ model: String?,
+ modelProviderName: String?,
+ reasoningEffort: String?,
+ turns: [TurnSchema],
+ agentMode: Bool,
+ customChatModeId: String?,
+ userLanguage: String?
+ ) async throws -> ConversationCreateResponse {
var conversationCreateTurns: [TurnSchema] = []
// invoke conversation history
if turns.count > 0 {
@@ -645,11 +690,19 @@ 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,
userLanguage: userLanguage)
do {
- _ = try await sendRequest(
+ return try await sendRequest(
GitHubCopilotRequest.CreateConversation(params: params))
} catch {
print("Failed to create conversation. Error: \(error)")
@@ -658,20 +711,24 @@ public final class GitHubCopilotService:
}
@GitHubCopilotSuggestionActor
- public func createTurn(_ message: MessageContent,
- workDoneToken: String,
- conversationId: String,
- turnId: String?,
- activeDoc: Doc?,
- ignoredSkills: [String]?,
- references: [ConversationAttachedReference],
- model: String?,
- modelProviderName: String?,
- workspaceFolder: String,
- workspaceFolders: [WorkspaceFolder]? = nil,
- agentMode: Bool) async throws {
+ public func createTurn(
+ _ message: MessageContent,
+ workDoneToken: String,
+ conversationId: String,
+ turnId: String?,
+ activeDoc: Doc?,
+ ignoredSkills: [String]?,
+ references: [ConversationAttachedReference],
+ model: String?,
+ modelProviderName: String?,
+ 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,
@@ -683,8 +740,12 @@ public final class GitHubCopilotService:
workspaceFolder: workspaceFolder,
workspaceFolders: workspaceFolders,
chatMode: agentMode ? "Agent" : nil,
+ customChatModeId: customChatModeId,
needToolCallConfirmation: true)
- _ = try await sendRequest(
+ if model != nil || reasoningEffort != nil {
+ params.modelInfo = ConversationModelInfo(id: model, providerName: modelProviderName, reasoningEffort: reasoningEffort)
+ }
+ return try await sendRequest(
GitHubCopilotRequest.CreateTurn(params: params))
} catch {
print("Failed to create turn. Error: \(error)")
@@ -714,6 +775,19 @@ public final class GitHubCopilotService:
throw error
}
}
+
+ @GitHubCopilotSuggestionActor
+ public func modes(workspaceFolders: [WorkspaceFolder]? = nil) async throws -> [ConversationMode] {
+ do {
+ let params = ConversationModesParams(workspaceFolders: workspaceFolders)
+ let response = try await sendRequest(
+ GitHubCopilotRequest.GetModes(params: params)
+ )
+ return response
+ } catch {
+ throw error
+ }
+ }
@GitHubCopilotSuggestionActor
public func models() async throws -> [CopilotModel] {
@@ -751,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 {
@@ -810,6 +889,18 @@ public final class GitHubCopilotService:
throw error
}
}
+
+ @GitHubCopilotSuggestionActor
+ public func getMCPRegistryAllowlist() async throws -> GetMCPRegistryAllowlistResult {
+ do {
+ let response = try await sendRequest(
+ GitHubCopilotRequest.MCPRegistryGetAllowlist()
+ )
+ return response
+ } catch {
+ throw error
+ }
+ }
@GitHubCopilotSuggestionActor
public func rateConversation(turnId: String, rating: ConversationRating) async throws {
@@ -854,6 +945,11 @@ public final class GitHubCopilotService:
GitHubCopilotRequest.NotifyShown(completionUUID: completion.id)
)
}
+
+ @GitHubCopilotSuggestionActor
+ public func notifyCopilotInlineEditShown(_ completion: CodeSuggestion) async {
+ try? await sendCopilotNotification(.textDocumentDidShowInlineEdit(.from(id: completion.id)))
+ }
@GitHubCopilotSuggestionActor
public func notifyAccepted(_ completion: CodeSuggestion, acceptedLength: Int? = nil) async {
@@ -861,6 +957,13 @@ public final class GitHubCopilotService:
GitHubCopilotRequest.NotifyAccepted(completionUUID: completion.id, acceptedLength: acceptedLength)
)
}
+
+ @GitHubCopilotSuggestionActor
+ public func notifyCopilotInlineEditAccepted(_ completion: CodeSuggestion) async {
+ _ = try? await sendRequest(
+ GitHubCopilotRequest.NotifyCopilotInlineEditAccepted(params: [completion.id])
+ )
+ }
@GitHubCopilotSuggestionActor
public func notifyRejected(_ completions: [CodeSuggestion]) async {
@@ -895,20 +998,18 @@ public final class GitHubCopilotService:
public func notifyChangeTextDocument(
fileURL: URL,
content: String,
- version: Int
+ version: Int,
+ contentChanges: [TextDocumentContentChangeEvent]? = nil
) async throws {
- let uri = "file://\(fileURL.path)"
+ let uri = fileURL.absoluteString
+ let changes: [TextDocumentContentChangeEvent] = contentChanges ?? [.init(range: nil, rangeLength: nil, text: content)]
// Logger.service.debug("Change \(uri), \(content.count)")
try await server.sendNotification(
.textDocumentDidChange(
DidChangeTextDocumentParams(
uri: uri,
version: version,
- contentChange: .init(
- range: nil,
- rangeLength: nil,
- text: content
- )
+ contentChanges: changes
)
)
)
@@ -1244,6 +1345,53 @@ public final class GitHubCopilotService:
return updatedTools
}
+ /// Refresh client tools by registering an empty list to get the latest tools from the server.
+ /// This is a workaround for the issue where server-side tools may not be ready when client tools are initially registered.
+ public static func refreshClientTools() async {
+ // Use the first available service since CopilotLanguageModelToolManager is shared
+ guard let service = services.first(where: { $0.projectRootURL.path != "/" }) else {
+ Logger.gitHubCopilot.error("No available service to refresh client tools")
+ return
+ }
+
+ do {
+ // Capture previous snapshot to detect newly added tools only
+ let previousNames = Set((CopilotLanguageModelToolManager.getAvailableLanguageModelTools() ?? []).map { $0.name })
+
+ // Register empty list to get the complete updated tool list from server
+ let refreshedTools = try await service.registerTools(tools: [])
+ CopilotLanguageModelToolManager.updateToolsStatus(refreshedTools)
+ Logger.gitHubCopilot.info("Refreshed client tools: \(refreshedTools.count) tools available (previous: \(previousNames.count))")
+
+ // Restore status ONLY for newly added tools whose saved status differs.
+ if let savedJSON = AppState.shared.get(key: "languageModelToolsStatus"),
+ let data = try? JSONEncoder().encode(savedJSON),
+ let savedStatusList = try? JSONDecoder().decode([ToolStatusUpdate].self, from: data),
+ !savedStatusList.isEmpty {
+ let refreshedByName = Dictionary(uniqueKeysWithValues: (CopilotLanguageModelToolManager.getAvailableLanguageModelTools() ?? []).map { ($0.name, $0) })
+ let newlyAddedNames = refreshedTools.map { $0.name }.filter { !previousNames.contains($0) }
+ if !newlyAddedNames.isEmpty {
+ let neededUpdates: [ToolStatusUpdate] = newlyAddedNames.compactMap { newName in
+ guard let saved = savedStatusList.first(where: { $0.name == newName }),
+ let current = refreshedByName[newName], current.status != saved.status else { return nil }
+ return saved
+ }
+ if !neededUpdates.isEmpty {
+ do {
+ let finalTools = try await service.updateToolsStatus(params: .init(tools: neededUpdates))
+ CopilotLanguageModelToolManager.updateToolsStatus(finalTools)
+ Logger.gitHubCopilot.info("Restored statuses for newly added tools: \(neededUpdates.map{ $0.name }.joined(separator: ", "))")
+ } catch {
+ Logger.gitHubCopilot.error("Failed to restore newly added tool statuses: \(error)")
+ }
+ }
+ }
+ }
+ } catch {
+ Logger.gitHubCopilot.error("Failed to refresh client tools: \(error)")
+ }
+ }
+
private func loadUnrestoredLanguageModelTools() -> [ToolStatusUpdate] {
if let savedJSON = AppState.shared.get(key: "languageModelToolsStatus"),
let data = try? JSONEncoder().encode(savedJSON),
@@ -1270,69 +1418,9 @@ public final class GitHubCopilotService:
Logger.gitHubCopilot.error("Failed to restore tools for service at \(projectRootURL.path): \(error)")
}
}
-
- private func loadUnrestoredMCPServers() -> [String] {
- if let savedJSON = AppState.shared.get(key: "mcpToolsStatus"),
- let data = try? JSONEncoder().encode(savedJSON),
- let savedStatus = try? JSONDecoder().decode([UpdateMCPToolsStatusServerCollection].self, from: data) {
- return savedStatus
- .filter { !$0.tools.isEmpty }
- .map { $0.name }
- }
-
- return []
- }
-
- private func restoreMCPToolsStatus(_ mcpServers: [String]) async -> [MCPServerToolsCollection]? {
- guard let savedJSON = AppState.shared.get(key: "mcpToolsStatus"),
- let data = try? JSONEncoder().encode(savedJSON),
- let savedStatus = try? JSONDecoder().decode([UpdateMCPToolsStatusServerCollection].self, from: data) else {
- Logger.gitHubCopilot.info("Failed to get MCP Tools status")
- return nil
- }
-
- do {
- let savedServers = savedStatus.filter { mcpServers.contains($0.name) }
- if savedServers.isEmpty {
- return nil
- } else {
- return try await updateMCPToolsStatus(
- params: .init(servers: savedServers)
- )
- }
- } catch let error as ServerError {
- Logger.gitHubCopilot.error("Failed to update MCP Tools status: \(GitHubCopilotError.languageServerError(error))")
- } catch {
- Logger.gitHubCopilot.error("Failed to update MCP Tools status: \(error)")
- }
-
- return nil
- }
public func handleMCPToolsNotification(_ notification: AnyJSONRPCNotification) async {
- defer {
- self.isMCPInitialized = true
- }
-
- if !self.isMCPInitialized {
- self.unrestoredMcpServers = self.loadUnrestoredMCPServers()
- }
-
if let payload = GetAllToolsParams.decode(fromParams: notification.params) {
- if !self.unrestoredMcpServers.isEmpty {
- // Find servers that need to be restored
- let toRestore = payload.servers.filter { !$0.tools.isEmpty }
- .filter { self.unrestoredMcpServers.contains($0.name) }
- .map { $0.name }
- self.unrestoredMcpServers.removeAll { toRestore.contains($0) }
-
- if let tools = await self.restoreMCPToolsStatus(toRestore) {
- Logger.gitHubCopilot.info("Restore MCP tools status for servers: \(toRestore)")
- CopilotMCPToolManager.updateMCPTools(tools)
- return
- }
- }
-
CopilotMCPToolManager.updateMCPTools(payload.servers)
}
}
@@ -1371,7 +1459,15 @@ public final class GitHubCopilotService:
let pathHash = String(workspacePath.hash.magnitude, radix: 36).prefix(6)
return "\(workspaceName)-\(pathHash)"
}
-
+
+ public static func getProjectGithubCopilotService(for projectRootURL: URL) -> GitHubCopilotService? {
+ if let existingService = services.first(where: { $0.projectRootURL == projectRootURL }) {
+ return existingService
+ } else {
+ return nil
+ }
+ }
+
public func handleSendWorkspaceDidChangeNotifications() {
Task {
if projectRootURL.path != "/" {
@@ -1386,9 +1482,34 @@ public final class GitHubCopilotService:
await sendConfigurationUpdate()
// Combine both notification streams
- let combinedNotifications = Publishers.Merge(
- NotificationCenter.default.publisher(for: .gitHubCopilotShouldRefreshEditorInformation).map { _ in "editorInfo" },
- FeatureFlagNotifierImpl.shared.featureFlagsDidChange.map { _ in "featureFlags" }
+ let combinedNotifications = Publishers.MergeMany(
+ NotificationCenter.default
+ .publisher(for: .gitHubCopilotShouldRefreshEditorInformation)
+ .map { _ in "editorInfo" }
+ .eraseToAnyPublisher(),
+ FeatureFlagNotifierImpl.shared.featureFlagsDidChange
+ .map { _ in "featureFlags" }
+ .eraseToAnyPublisher(),
+ DistributedNotificationCenter.default()
+ .publisher(for: .githubCopilotAgentMaxToolCallingLoopDidChange)
+ .map { _ in "agentMaxToolCallingLoop" }
+ .eraseToAnyPublisher(),
+ DistributedNotificationCenter.default()
+ .publisher(for: .githubCopilotAgentAutoApprovalDidChange)
+ .map { _ in "agentAutoApproval" }
+ .eraseToAnyPublisher(),
+ NotificationCenter.default
+ .publisher(for: .githubCopilotAgentAutoApprovalDidChange)
+ .map { _ in "agentAutoApproval" }
+ .eraseToAnyPublisher(),
+ DistributedNotificationCenter.default()
+ .publisher(for: .githubCopilotAgentTrustToolAnnotationsDidChange)
+ .map { _ in "agentTrustToolAnnotations" }
+ .eraseToAnyPublisher(),
+ DistributedNotificationCenter.default()
+ .publisher(for: .githubCopilotAgentAutoCompressDidChange)
+ .map { _ in "agentAutoCompress" }
+ .eraseToAnyPublisher()
)
for await _ in combinedNotifications.values {
diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/ServerNotificationHandler.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/ServerNotificationHandler.swift
index 39c2c4a5..2bacb348 100644
--- a/Tool/Sources/GitHubCopilotService/LanguageServer/ServerNotificationHandler.swift
+++ b/Tool/Sources/GitHubCopilotService/LanguageServer/ServerNotificationHandler.swift
@@ -13,6 +13,10 @@ class ServerNotificationHandlerImpl: ServerNotificationHandler {
var protocolProgressSubject: PassthroughSubject
var conversationProgressHandler: ConversationProgressHandler = ConversationProgressHandlerImpl.shared
var featureFlagNotifier: FeatureFlagNotifier = FeatureFlagNotifierImpl.shared
+ var copilotPolicyNotifier: CopilotPolicyNotifier = CopilotPolicyNotifierImpl.shared
+ var compressionHandler: CompressionHandler = CompressionHandlerImpl.shared
+ var rateLimitNotifier: RateLimitNotifier = RateLimitNotifierImpl.shared
+ var quotaNotifier: QuotaNotifier = QuotaNotifierImpl.shared
init() {
self.protocolProgressSubject = PassthroughSubject()
@@ -44,6 +48,54 @@ class ServerNotificationHandlerImpl: ServerNotificationHandler {
featureFlagNotifier.handleFeatureFlagNotification(didChangeFeatureFlagsParams)
}
break
+ case "policy/didChange":
+ if let data = try? JSONEncoder().encode(notification.params),
+ let policy = try? JSONDecoder().decode(
+ CopilotPolicy.self,
+ from: data
+ ) {
+ copilotPolicyNotifier.handleCopilotPolicyNotification(policy)
+ }
+ break
+ case "$/copilot/compressionStarted":
+ if let payload = GitHubCopilotNotification.CompressionStartedNotification
+ .decode(fromParams: notification.params) {
+ compressionHandler.onCompressionStarted.send(payload.conversationId)
+ }
+ break
+ case "$/copilot/compressionCompleted":
+ if let payload = GitHubCopilotNotification.CompressionCompletedNotification
+ .decode(fromParams: notification.params) {
+ compressionHandler.onCompressionCompleted.send(payload)
+ }
+ break
+ 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/LanguageServer/ServerRequestHandler.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/ServerRequestHandler.swift
index 7b28b73b..eb61fa50 100644
--- a/Tool/Sources/GitHubCopilotService/LanguageServer/ServerRequestHandler.swift
+++ b/Tool/Sources/GitHubCopilotService/LanguageServer/ServerRequestHandler.swift
@@ -1,6 +1,6 @@
-import Foundation
-import ConversationServiceProvider
import Combine
+import ConversationServiceProvider
+import Foundation
import JSONRPC
import LanguageClient
import LanguageServerProtocol
@@ -10,91 +10,112 @@ public typealias ResponseHandler = ServerRequest.Handler
public typealias LegacyResponseHandler = (AnyJSONRPCResponse) -> Void
protocol ServerRequestHandler {
- func handleRequest(_ request: AnyJSONRPCRequest, workspaceURL: URL, callback: @escaping ResponseHandler, service: GitHubCopilotService?)
+ func handleRequest(id: JSONId, _ request: ServerRequest, workspaceURL: URL, service: GitHubCopilotService?)
}
-class ServerRequestHandlerImpl : ServerRequestHandler {
+class ServerRequestHandlerImpl: ServerRequestHandler {
public static let shared = ServerRequestHandlerImpl()
private let conversationContextHandler: ConversationContextHandler = ConversationContextHandlerImpl.shared
private let watchedFilesHandler: WatchedFilesHandler = WatchedFilesHandlerImpl.shared
private let showMessageRequestHandler: ShowMessageRequestHandler = ShowMessageRequestHandlerImpl.shared
- private let mcpOAuthRequestHandler: MCPOAuthRequestHandler = MCPOAuthRequestHandlerImpl.shared
+ private let dynamicOAuthRequestHandler: DynamicOAuthRequestHandler = DynamicOAuthRequestHandlerImpl.shared
+
+ func handleRequest(id: JSONId, _ request: ServerRequest, workspaceURL: URL, service: GitHubCopilotService?) {
+ switch request {
+ case let .windowShowMessageRequest(params, callback):
+ if workspaceURL.path != "/" {
+ do {
+ let paramsData = try JSONEncoder().encode(params)
+ let showMessageRequestParams = try JSONDecoder().decode(ShowMessageRequestParams.self, from: paramsData)
- func handleRequest(_ request: AnyJSONRPCRequest, workspaceURL: URL, callback: @escaping ResponseHandler, service: GitHubCopilotService?) {
- let methodName = request.method
- let legacyResponseHandler = toLegacyResponseHandler(callback)
- do {
- switch methodName {
- case "conversation/context":
- let params = try JSONEncoder().encode(request.params)
- let contextParams = try JSONDecoder().decode(ConversationContextParams.self, from: params)
- conversationContextHandler.handleConversationContext(
- ConversationContextRequest(id: request.id, method: request.method, params: contextParams),
- completion: legacyResponseHandler)
-
- case "copilot/watchedFiles":
- let params = try JSONEncoder().encode(request.params)
- let watchedFilesParams = try JSONDecoder().decode(WatchedFilesParams.self, from: params)
- watchedFilesHandler.handleWatchedFiles(WatchedFilesRequest(id: request.id, method: request.method, params: watchedFilesParams), workspaceURL: workspaceURL, completion: legacyResponseHandler, service: service)
-
- case "window/showMessageRequest":
- let params = try JSONEncoder().encode(request.params)
- let showMessageRequestParams = try JSONDecoder().decode(ShowMessageRequestParams.self, from: params)
- showMessageRequestHandler
- .handleShowMessage(
+ showMessageRequestHandler.handleShowMessageRequest(
ShowMessageRequest(
- id: request.id,
- method: request.method,
+ id: id,
+ method: "window/showMessageRequest",
params: showMessageRequestParams
),
+ callback: callback
+ )
+ } catch {
+ Task {
+ await callback(.success(nil))
+ }
+ }
+ }
+
+ case let .custom(method, params, callback):
+ let legacyResponseHandler = toLegacyResponseHandler(callback)
+ do {
+ switch method {
+ case "conversation/context":
+ let paramsData = try JSONEncoder().encode(params)
+ let contextParams = try JSONDecoder().decode(ConversationContextParams.self, from: paramsData)
+ conversationContextHandler.handleConversationContext(
+ ConversationContextRequest(id: id, method: method, params: contextParams),
completion: legacyResponseHandler
)
- case "conversation/invokeClientTool":
- let params = try JSONEncoder().encode(request.params)
- let invokeParams = try JSONDecoder().decode(InvokeClientToolParams.self, from: params)
- ClientToolHandlerImpl.shared.invokeClientTool(InvokeClientToolRequest(id: request.id, method: request.method, params: invokeParams), completion: legacyResponseHandler)
+ case "copilot/watchedFiles":
+ let paramsData = try JSONEncoder().encode(params)
+ let watchedFilesParams = try JSONDecoder().decode(WatchedFilesParams.self, from: paramsData)
+ watchedFilesHandler.handleWatchedFiles(
+ WatchedFilesRequest(id: id, method: method, params: watchedFilesParams),
+ workspaceURL: workspaceURL,
+ completion: legacyResponseHandler,
+ service: service
+ )
- case "conversation/invokeClientToolConfirmation":
- let params = try JSONEncoder().encode(request.params)
- let invokeParams = try JSONDecoder().decode(InvokeClientToolParams.self, from: params)
- ClientToolHandlerImpl.shared.invokeClientToolConfirmation(InvokeClientToolConfirmationRequest(id: request.id, method: request.method, params: invokeParams), completion: legacyResponseHandler)
+ case "conversation/invokeClientTool":
+ let paramsData = try JSONEncoder().encode(params)
+ let invokeParams = try JSONDecoder().decode(InvokeClientToolParams.self, from: paramsData)
+ ClientToolHandlerImpl.shared.invokeClientTool(
+ InvokeClientToolRequest(id: id, method: method, params: invokeParams),
+ completion: legacyResponseHandler
+ )
- case "copilot/mcpOAuth":
- let params = try JSONEncoder().encode(request.params)
- let mcpOAuthRequestParams = try JSONDecoder().decode(MCPOAuthRequestParams.self, from: params)
- mcpOAuthRequestHandler.handleShowOAuthMessage(
- MCPOAuthRequest(
- id: request.id,
- method: request.method,
- params: mcpOAuthRequestParams
- ),
- completion: legacyResponseHandler
- )
+ case "conversation/invokeClientToolConfirmation":
+ let paramsData = try JSONEncoder().encode(params)
+ let invokeParams = try JSONDecoder().decode(InvokeClientToolParams.self, from: paramsData)
+ ClientToolHandlerImpl.shared.invokeClientToolConfirmation(
+ InvokeClientToolConfirmationRequest(id: id, method: method, params: invokeParams),
+ completion: legacyResponseHandler
+ )
+
+ case "copilot/dynamicOAuth":
+ let paramsData = try JSONEncoder().encode(params)
+ let dynamicOAuthParams = try JSONDecoder().decode(DynamicOAuthParams.self, from: paramsData)
+ DynamicOAuthRequestHandlerImpl.shared.handleDynamicOAuthRequest(
+ DynamicOAuthRequest(id: id, method: method, params: dynamicOAuthParams),
+ completion: legacyResponseHandler
+ )
- default:
- break
+ default:
+ break
+ }
+ } catch {
+ handleError(id: id, method: method, error: error, callback: legacyResponseHandler)
}
- } catch {
- handleError(request, error: error, callback: legacyResponseHandler)
+
+ default:
+ break
}
}
-
- private func handleError(_ request: AnyJSONRPCRequest, error: Error, callback: @escaping (AnyJSONRPCResponse) -> Void) {
+
+ private func handleError(id: JSONId, method: String, error: Error, callback: @escaping (AnyJSONRPCResponse) -> Void) {
callback(
AnyJSONRPCResponse(
- id: request.id,
+ id: id,
result: JSONValue.array([
JSONValue.null,
JSONValue.hash([
- "code": .number(-32602/* Invalid params */),
- "message": .string("Error: \(error.localizedDescription)")])
+ "code": .number(-32602 /* Invalid params */ ),
+ "message": .string("Error handling \(method): \(error.localizedDescription)")]),
])
)
)
Logger.gitHubCopilot.error(error)
}
-
+
/// Converts a new Handler to work with old code that expects LegacyResponseHandler
private func toLegacyResponseHandler(
_ newHandler: @escaping ResponseHandler
diff --git a/Tool/Sources/GitHubCopilotService/Services/CopilotPolicyNotifier.swift b/Tool/Sources/GitHubCopilotService/Services/CopilotPolicyNotifier.swift
new file mode 100644
index 00000000..5072ae12
--- /dev/null
+++ b/Tool/Sources/GitHubCopilotService/Services/CopilotPolicyNotifier.swift
@@ -0,0 +1,53 @@
+import Combine
+import SwiftUI
+import JSONRPC
+
+public extension Notification.Name {
+ static let gitHubCopilotPolicyDidChange = Notification
+ .Name("com.github.CopilotForXcode.CopilotPolicyDidChange")
+}
+
+public struct CopilotPolicy: Hashable, Codable {
+ public var mcpContributionPointEnabled: Bool = true
+ public var customAgentEnabled: Bool = true
+ public var subagentEnabled: Bool = true
+ public var cveRemediatorAgentEnabled: Bool = true
+ public var agentModeAutoApprovalEnabled: Bool = false
+
+ enum CodingKeys: String, CodingKey {
+ case mcpContributionPointEnabled = "mcp.contributionPoint.enabled"
+ case customAgentEnabled = "customAgent.enabled"
+ case subagentEnabled = "subagent.enabled"
+ case cveRemediatorAgentEnabled = "cveRemediatorAgent.enabled"
+ case agentModeAutoApprovalEnabled = "agentMode.autoApproval.enabled"
+ }
+}
+
+public protocol CopilotPolicyNotifier {
+ var copilotPolicy: CopilotPolicy { get }
+ var policyDidChange: PassthroughSubject { get }
+ func handleCopilotPolicyNotification(_ policy: CopilotPolicy)
+}
+
+public class CopilotPolicyNotifierImpl: CopilotPolicyNotifier {
+ public private(set) var copilotPolicy: CopilotPolicy
+ public static let shared = CopilotPolicyNotifierImpl()
+ public var policyDidChange: PassthroughSubject
+
+ init(
+ copilotPolicy: CopilotPolicy = CopilotPolicy(),
+ policyDidChange: PassthroughSubject = PassthroughSubject()
+ ) {
+ self.copilotPolicy = copilotPolicy
+ self.policyDidChange = policyDidChange
+ }
+
+ public func handleCopilotPolicyNotification(_ policy: CopilotPolicy) {
+ self.copilotPolicy = policy
+ DispatchQueue.main.async { [weak self] in
+ guard let self else { return }
+ self.policyDidChange.send(self.copilotPolicy)
+ DistributedNotificationCenter.default().post(name: .gitHubCopilotPolicyDidChange, object: nil)
+ }
+ }
+}
diff --git a/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift b/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift
index 8b343d61..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 8879b677..0038a1e1 100644
--- a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift
+++ b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift
@@ -40,8 +40,10 @@ public final class GitHubCopilotConversationService: ConversationServiceType {
return message
}
- public func createConversation(_ request: ConversationRequest, workspace: WorkspaceInfo) async throws {
- guard let service = await serviceLocator.getService(from: workspace) else { return }
+ public func createConversation(
+ _ request: ConversationRequest, workspace: WorkspaceInfo
+ ) async throws -> ConversationCreateResponse? {
+ guard let service = await serviceLocator.getService(from: workspace) else { return nil }
let message = getMessageContent(request)
@@ -55,13 +57,17 @@ 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,
userLanguage: request.userLanguage)
}
- public func createTurn(with conversationId: String, request: ConversationRequest, workspace: WorkspaceInfo) async throws {
- guard let service = await serviceLocator.getService(from: workspace) else { return }
+ public func createTurn(
+ with conversationId: String, request: ConversationRequest, workspace: WorkspaceInfo
+ ) async throws -> ConversationCreateResponse? {
+ guard let service = await serviceLocator.getService(from: workspace) else { return nil }
let message = getMessageContent(request)
@@ -74,9 +80,11 @@ 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)
+ agentMode: request.agentMode,
+ customChatModeId: request.customChatModeId)
}
public func deleteTurn(with conversationId: String, turnId: String, workspace: WorkspaceInfo) async throws {
@@ -107,6 +115,15 @@ public final class GitHubCopilotConversationService: ConversationServiceType {
let workspaceFolders = isPreviewEnabled ? getWorkspaceFolders(workspace: workspace) : nil
return try await service.templates(workspaceFolders: workspaceFolders)
}
+
+ public func modes(workspace: WorkspaceInfo) async throws -> [ConversationMode]? {
+ guard let service = await serviceLocator.getService(from: workspace) else { return nil }
+ let isCustomAgentEnabled = CopilotPolicyNotifierImpl.shared.copilotPolicy.customAgentEnabled
+ let workspaceFolders = isCustomAgentEnabled ? getWorkspaceFolders(
+ workspace: workspace
+ ) : nil
+ return try await service.modes(workspaceFolders: workspaceFolders)
+ }
public func models(workspace: WorkspaceInfo) async throws -> [CopilotModel]? {
guard let service = await serviceLocator.getService(from: workspace) else { return nil }
@@ -138,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/GitHubCopilotSuggestionService.swift b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotSuggestionService.swift
index f9f8a9b5..b135fb65 100644
--- a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotSuggestionService.swift
+++ b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotSuggestionService.swift
@@ -2,8 +2,9 @@ import CopilotForXcodeKit
import Foundation
import SuggestionBasic
import Workspace
+import SuggestionProvider
-public final class GitHubCopilotSuggestionService: SuggestionServiceType {
+public final class GitHubCopilotSuggestionService: SuggestionServiceType, NESSuggestionServiceType {
public var configuration: SuggestionServiceConfiguration {
.init(
acceptsRelevantCodeSnippets: true,
@@ -19,7 +20,7 @@ public final class GitHubCopilotSuggestionService: SuggestionServiceType {
}
public func getSuggestions(
- _ request: SuggestionRequest,
+ _ request: CopilotForXcodeKit.SuggestionRequest,
workspace: WorkspaceInfo
) async throws -> [CopilotForXcodeKit.CodeSuggestion] {
guard let service = await serviceLocator.getService(from: workspace) else { return [] }
@@ -36,6 +37,21 @@ public final class GitHubCopilotSuggestionService: SuggestionServiceType {
usesTabsForIndentation: request.usesTabsForIndentation
).map(Self.convert)
}
+
+ public func getNESSuggestions(
+ _ request: CopilotForXcodeKit.SuggestionRequest,
+ workspace: WorkspaceInfo
+ ) async throws -> [CopilotForXcodeKit.CodeSuggestion] {
+ guard let service = await serviceLocator.getService(from: workspace) else { return [] }
+
+ return try await service
+ .getCopilotInlineEdit(
+ fileURL: request.fileURL,
+ content: request.content,
+ cursorPosition: .init(line: request.cursorPosition.line, character: request.cursorPosition.character)
+ )
+ .map(Self.convert)
+ }
public func notifyAccepted(
_ suggestion: CopilotForXcodeKit.CodeSuggestion,
diff --git a/Tool/Sources/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 28172e69..2274c13d 100644
--- a/Tool/Sources/HostAppActivator/HostAppActivator.swift
+++ b/Tool/Sources/HostAppActivator/HostAppActivator.swift
@@ -9,8 +9,14 @@ public extension Notification.Name {
.Name("com.github.CopilotForXcode.OpenSettingsWindowRequest")
static let openToolsSettingsWindowRequest = Notification
.Name("com.github.CopilotForXcode.OpenToolsSettingsWindowRequest")
+ static let openToolsSettingsAutoApproveWindowRequest = Notification
+ .Name("com.github.CopilotForXcode.OpenToolsSettingsAutoApproveWindowRequest")
static let openBYOKSettingsWindowRequest = Notification
.Name("com.github.CopilotForXcode.OpenBYOKSettingsWindowRequest")
+ static let openAdvancedSettingsWindowRequest = Notification
+ .Name("com.github.CopilotForXcode.OpenAdvancedSettingsWindowRequest")
+ static let selectedAgentSubModeDidChange = Notification
+ .Name("com.github.CopilotForXcode.SelectedAgentSubModeDidChange")
}
public enum GitHubCopilotForXcodeSettingsLaunchError: Error, LocalizedError {
@@ -39,24 +45,21 @@ public func launchHostAppSettings() throws {
let activated = hostApp.activate(options: [.activateIgnoringOtherApps])
Logger.ui.info("\(hostAppName()) activated: \(activated)")
- let scriptSuccess = tryLaunchWithAppleScript()
-
- // If AppleScript fails, fall back to notification center
- if !scriptSuccess {
- DistributedNotificationCenter.default().postNotificationName(
- .openSettingsWindowRequest,
- object: nil
- )
- Logger.ui.info("\(hostAppName()) settings notification sent after activation")
- return
- }
+ _ = tryLaunchWithAppleScript()
+
+ DistributedNotificationCenter.default().postNotificationName(
+ .openSettingsWindowRequest,
+ object: nil
+ )
+ Logger.ui.info("\(hostAppName()) settings notification sent after activation")
+ return
} else {
// If app is not running, launch it with the settings flag
try launchHostAppWithArgs(args: ["--settings"])
}
}
-public func launchHostAppToolsSettings() throws {
+public func launchHostAppToolsSettings(currentAgentSubMode: String) throws {
// Try the AppleScript approach first, but only if app is already running
if let hostApp = getRunningHostApp() {
let activated = hostApp.activate(options: [.activateIgnoringOtherApps])
@@ -68,6 +71,15 @@ public func launchHostAppToolsSettings() throws {
.openToolsSettingsWindowRequest,
object: nil
)
+
+ // Notify settings app of current agent submode
+ DistributedNotificationCenter.default().postNotificationName(
+ .selectedAgentSubModeDidChange,
+ object: nil,
+ userInfo: ["agentSubMode": currentAgentSubMode],
+ options: .deliverImmediately
+ )
+
Logger.ui.info("\(hostAppName()) MCP settings notification sent after activation")
return
} else {
@@ -76,6 +88,27 @@ public func launchHostAppToolsSettings() throws {
}
}
+public func launchHostAppToolsSettingsAutoApprove() throws {
+ // Try the AppleScript approach first, but only if app is already running
+ if let hostApp = getRunningHostApp() {
+ let activated = hostApp.activate(options: [.activateIgnoringOtherApps])
+ Logger.ui.info("\(hostAppName()) activated: \(activated)")
+
+ _ = tryLaunchWithAppleScript()
+
+ DistributedNotificationCenter.default().postNotificationName(
+ .openToolsSettingsAutoApproveWindowRequest,
+ object: nil
+ )
+
+ Logger.ui.info("\(hostAppName()) MCP settings (Auto-Approve) notification sent after activation")
+ return
+ } else {
+ // If app is not running, launch it with the settings flag
+ try launchHostAppWithArgs(args: ["--tools-auto-approve"])
+ }
+}
+
public func launchHostAppBYOKSettings() throws {
// Try the AppleScript approach first, but only if app is already running
if let hostApp = getRunningHostApp() {
@@ -96,6 +129,26 @@ public func launchHostAppBYOKSettings() throws {
}
}
+public func launchHostAppAdvancedSettings() throws {
+ // Try the AppleScript approach first, but only if app is already running
+ if let hostApp = getRunningHostApp() {
+ let activated = hostApp.activate(options: [.activateIgnoringOtherApps])
+ Logger.ui.info("\(hostAppName()) activated: \(activated)")
+
+ _ = tryLaunchWithAppleScript()
+
+ DistributedNotificationCenter.default().postNotificationName(
+ .openAdvancedSettingsWindowRequest,
+ object: nil
+ )
+ Logger.ui.info("\(hostAppName()) Advanced settings notification sent after activation")
+ return
+ } else {
+ // If app is not running, launch it with the settings flag
+ try launchHostAppWithArgs(args: ["--advanced"])
+ }
+}
+
private func tryLaunchWithAppleScript() -> Bool {
// Try to launch settings using AppleScript
let script = """
@@ -163,3 +216,5 @@ func hostAppName() -> String {
return Bundle.main.object(forInfoDictionaryKey: "HOST_APP_NAME") as? String
?? "GitHub Copilot for Xcode"
}
+
+public let SELECTED_AGENT_SUBMODE_KEY = "selectedAgentSubMode"
diff --git a/Tool/Sources/Logger/MCPRuntimeLogger.swift b/Tool/Sources/Logger/MCPRuntimeLogger.swift
index 36527e43..d633b440 100644
--- a/Tool/Sources/Logger/MCPRuntimeLogger.swift
+++ b/Tool/Sources/Logger/MCPRuntimeLogger.swift
@@ -2,16 +2,18 @@ import Foundation
import System
public final class MCPRuntimeFileLogger {
- private let timestampFormat = Date.ISO8601FormatStyle.iso8601
- .year()
- .month()
- .day()
- .timeZone(separator: .omitted).time(includingFractionalSeconds: true)
- private static let implementation = MCPRuntimeFileLoggerImplementation()
-
+ private lazy var dateFormatter: ISO8601DateFormatter = {
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ return formatter
+ }()
+
+ private let implementation = MCPRuntimeFileLoggerImplementation()
+
/// Converts a timestamp in milliseconds since the Unix epoch to a formatted date string.
private func timestamp(timeStamp: Double) -> String {
- return Date(timeIntervalSince1970: timeStamp/1000).formatted(timestampFormat)
+ let date = Date(timeIntervalSince1970: timeStamp/1000)
+ return dateFormatter.string(from: date)
}
public func log(
@@ -22,10 +24,16 @@ public final class MCPRuntimeFileLogger {
tool: String? = nil,
time: Double
) {
- let log = "[\(timestamp(timeStamp: time))] [\(level)] [\(server)\(tool == nil ? "" : "-\(tool!))")] \(message)\(message.hasSuffix("\n") ? "" : "\n")"
+ guard time.isFinite, time >= 0 else {
+ return
+ }
+
+ let toolSuffix = tool.map { "-\($0)" } ?? ""
+ let timestampStr = timestamp(timeStamp: time)
+ let log = "[\(timestampStr)] [\(level)] [\(server)\(toolSuffix)] \(message)\(message.hasSuffix("\n") ? "" : "\n")"
- Task {
- await MCPRuntimeFileLogger.implementation.logToFile(logFileName: logFileName, log: log)
+ Task { [implementation] in
+ await implementation.logToFile(logFileName: logFileName, log: log)
}
}
}
diff --git a/Tool/Sources/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 d0aad829..400c07e3 100644
--- a/Tool/Sources/Preferences/Keys.swift
+++ b/Tool/Sources/Preferences/Keys.swift
@@ -166,6 +166,10 @@ public extension UserDefaultPreferenceKeys {
var realtimeSuggestionToggle: PreferenceKey {
.init(defaultValue: true, key: "RealtimeSuggestionToggle")
}
+
+ var realtimeNESToggle: PreferenceKey {
+ .init(defaultValue: true, key: "RealtimeNESToggle")
+ }
var suggestionDisplayCompactMode: PreferenceKey {
.init(defaultValue: true, key: "SuggestionDisplayCompactMode")
@@ -308,6 +312,10 @@ public extension UserDefaultPreferenceKeys {
var chatResponseLocale: PreferenceKey {
.init(defaultValue: "en", key: "ChatResponseLocale")
}
+
+ var agentMaxToolCallingLoop: PreferenceKey {
+ .init(defaultValue: 25, key: "AgentMaxToolCallingLoop")
+ }
var globalCopilotInstructions: PreferenceKey {
.init(defaultValue: "", key: "GlobalCopilotInstructions")
@@ -324,6 +332,14 @@ public extension UserDefaultPreferenceKeys {
var suppressRestoreCheckpointConfirmation: PreferenceKey {
.init(defaultValue: false, key: "SuppressRestoreCheckpointConfirmation")
}
+
+ var enableSubagent: PreferenceKey {
+ .init(defaultValue: true, key: "EnableSubagent")
+ }
+
+ var autoCompress: PreferenceKey {
+ .init(defaultValue: true, key: "AutoCompress")
+ }
}
// MARK: - Theme
@@ -603,4 +619,36 @@ public extension UserDefaultPreferenceKeys {
var currentUserName: PreferenceKey {
.init(defaultValue: "", key: "CurrentUserName")
}
+
+ var mcpRegistryBaseURL: PreferenceKey {
+ .init(defaultValue: "https://api.mcp.github.com", key: "MCPRegistryBaseURL")
+ }
+
+ var mcpRegistryBaseURLHistory: PreferenceKey<[String]> {
+ .init(defaultValue: [], key: "MCPRegistryBaseURLHistory")
+ }
+}
+
+// MARK: - Auto Approval
+public extension UserDefaultPreferenceKeys {
+
+ var enableAutoApproval: PreferenceKey {
+ .init(defaultValue: false, key: "EnableAutoApproval")
+ }
+
+ var trustToolAnnotations: PreferenceKey {
+ .init(defaultValue: false, key: "TrustToolAnnotations")
+ }
+
+ var sensitiveFilesGlobalApprovals: PreferenceKey {
+ .init(defaultValue: SensitiveFilesRules(), key: "AutoApproval_SensitiveFiles_GlobalApprovals")
+ }
+
+ var mcpServersGlobalApprovals: PreferenceKey {
+ .init(defaultValue: AutoApprovedMCPServers(), key: "AutoApproval_MCP_GlobalApprovals")
+ }
+
+ var terminalCommandsGlobalApprovals: PreferenceKey {
+ .init(defaultValue: TerminalCommandsRules(), key: "AutoApproval_Terminal_GlobalApprovals")
+ }
}
diff --git a/Tool/Sources/Preferences/Types/AutoApprovedMCPServers.swift b/Tool/Sources/Preferences/Types/AutoApprovedMCPServers.swift
new file mode 100644
index 00000000..902f4f02
--- /dev/null
+++ b/Tool/Sources/Preferences/Types/AutoApprovedMCPServers.swift
@@ -0,0 +1,47 @@
+import Foundation
+
+public struct MCPServerApprovalState: Codable, Equatable {
+ public var isServerAllowed: Bool
+ public var allowedTools: Set
+
+ public init(isServerAllowed: Bool = false, allowedTools: Set = []) {
+ self.isServerAllowed = isServerAllowed
+ self.allowedTools = allowedTools
+ }
+}
+
+public struct AutoApprovedMCPServers: Codable, Equatable, RawRepresentable {
+ public var servers: [String: MCPServerApprovalState]
+
+ public init(servers: [String: MCPServerApprovalState] = [:]) {
+ self.servers = servers
+ }
+
+ public init?(rawValue: [String: Any]) {
+ let serversDict = rawValue["servers"] as? [String: Any] ?? [:]
+ var parsedServers: [String: MCPServerApprovalState] = [:]
+
+ for (serverName, value) in serversDict {
+ if let dict = value as? [String: Any] {
+ let isServerAllowed = dict["isServerAllowed"] as? Bool ?? false
+ let allowedToolsArray = dict["allowedTools"] as? [String] ?? []
+ parsedServers[serverName] = MCPServerApprovalState(
+ isServerAllowed: isServerAllowed,
+ allowedTools: Set(allowedToolsArray)
+ )
+ }
+ }
+ self.servers = parsedServers
+ }
+
+ public var rawValue: [String: Any] {
+ var serversDict: [String: Any] = [:]
+ for (serverName, state) in servers {
+ serversDict[serverName] = [
+ "isServerAllowed": state.isServerAllowed,
+ "allowedTools": Array(state.allowedTools)
+ ]
+ }
+ return ["servers": serversDict]
+ }
+}
diff --git a/Tool/Sources/Preferences/Types/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 56c19b47..9055c3c3 100644
--- a/Tool/Sources/Preferences/UserDefaults.swift
+++ b/Tool/Sources/Preferences/UserDefaults.swift
@@ -10,13 +10,23 @@ public protocol UserDefaultsType {
public extension UserDefaults {
static var shared = UserDefaults(suiteName: userDefaultSuiteName)!
+ /// Workspace-level auto-approval storage.
+ ///
+ /// Backed by the `group..autoApproval.prefs` suite so it persists
+ /// across app restarts and is isolated from general app preferences.
+ static var autoApproval = UserDefaults(suiteName: autoApprovalUserDefaultSuiteName)!
+
static func setupDefaultSettings() {
shared.setupDefaultValue(for: \.quitXPCServiceOnXcodeAndAppQuit)
shared.setupDefaultValue(for: \.realtimeSuggestionToggle)
+ shared.setupDefaultValue(for: \.realtimeNESToggle)
shared.setupDefaultValue(for: \.realtimeSuggestionDebounce)
shared.setupDefaultValue(for: \.suggestionPresentationMode)
shared.setupDefaultValue(for: \.autoAttachChatToXcode)
shared.setupDefaultValue(for: \.enableFixError)
+ shared.setupDefaultValue(for: \.enableSubagent)
+ shared.setupDefaultValue(for: \.enableAutoApproval)
+ shared.setupDefaultValue(for: \.trustToolAnnotations)
shared.setupDefaultValue(for: \.widgetColorScheme)
shared.setupDefaultValue(for: \.customCommands)
shared.setupDefaultValue(
@@ -79,8 +89,10 @@ extension Bool: UserDefaultsStorable {}
extension String: UserDefaultsStorable {}
extension Data: UserDefaultsStorable {}
extension URL: UserDefaultsStorable {}
+extension Dictionary: UserDefaultsStorable {}
+
-extension Array: RawRepresentable where Element: Codable {
+extension Array: @retroactive RawRepresentable where Element: Codable {
public init?(rawValue: String) {
guard let data = rawValue.data(using: .utf8),
let result = try? JSONDecoder().decode([Element].self, from: data)
@@ -309,3 +321,35 @@ public extension UserDefaultsType {
}
}
+public extension UserDefaultsType {
+ // MARK: Dictionary Raw Representable
+
+ func value(
+ for keyPath: KeyPath
+ ) -> K.Value where K.Value: RawRepresentable, K.Value.RawValue == [String: Any] {
+ let key = UserDefaultPreferenceKeys()[keyPath: keyPath]
+ guard let rawValue = value(forKey: key.key) as? [String: Any] else {
+ return key.defaultValue
+ }
+ return K.Value(rawValue: rawValue) ?? key.defaultValue
+ }
+
+ func set(
+ _ value: K.Value,
+ for keyPath: KeyPath
+ ) where K.Value: RawRepresentable, K.Value.RawValue == [String: Any] {
+ let key = UserDefaultPreferenceKeys()[keyPath: keyPath]
+ set(value.rawValue, forKey: key.key)
+ }
+
+ func setupDefaultValue(
+ for keyPath: KeyPath,
+ defaultValue: K.Value? = nil
+ ) where K.Value: RawRepresentable, K.Value.RawValue == [String: Any] {
+ let key = UserDefaultPreferenceKeys()[keyPath: keyPath]
+ if value(forKey: key.key) == nil {
+ set(defaultValue?.rawValue ?? key.defaultValue.rawValue, forKey: key.key)
+ }
+ }
+}
+
diff --git a/Core/Sources/HostApp/SharedComponents/AdaptiveHelpLink.swift b/Tool/Sources/SharedUIComponents/AdaptiveHelpLink.swift
similarity index 84%
rename from Core/Sources/HostApp/SharedComponents/AdaptiveHelpLink.swift
rename to Tool/Sources/SharedUIComponents/AdaptiveHelpLink.swift
index 54ee7e9c..5e06037b 100644
--- a/Core/Sources/HostApp/SharedComponents/AdaptiveHelpLink.swift
+++ b/Tool/Sources/SharedUIComponents/AdaptiveHelpLink.swift
@@ -2,16 +2,16 @@ import SwiftUI
/// A small adaptive help link button that uses the native `HelpLink` on macOS 14+
/// and falls back to a styled question-mark button on earlier versions.
-struct AdaptiveHelpLink: View {
+public struct AdaptiveHelpLink: View {
let action: () -> Void
var controlSize: ControlSize = .small
- init(controlSize: ControlSize = .small, action: @escaping () -> Void) {
+ public init(controlSize: ControlSize = .small, action: @escaping () -> Void) {
self.controlSize = controlSize
self.action = action
}
- var body: some View {
+ public var body: some View {
Group {
if #available(macOS 14.0, *) {
HelpLink(action: action)
diff --git a/Tool/Sources/SharedUIComponents/Base/Colors.swift b/Tool/Sources/SharedUIComponents/Base/Colors.swift
index 62a4a19d..9ab89738 100644
--- a/Tool/Sources/SharedUIComponents/Base/Colors.swift
+++ b/Tool/Sources/SharedUIComponents/Base/Colors.swift
@@ -6,4 +6,40 @@ public extension Color {
static var chatWindowBackgroundColor: Color { Color("ChatWindowBackgroundColor") }
static var successLightGreen: Color { Color("LightGreen") }
+
+ static var agentToolStatusDividerColor: Color { Color("AgentToolStatusDividerColor") }
+
+ static var agentToolStatusOutlineColor: Color { Color("AgentToolStatusOutlineColor") }
+}
+
+public var QuinarySystemFillColor: Color {
+ if #available(macOS 14.0, *) {
+ return Color(nsColor: .quinarySystemFill)
+ } else {
+ return Color("QuinarySystemFillColor")
+ }
+}
+
+public var QuaternarySystemFillColor: Color {
+ if #available(macOS 14.0, *) {
+ return Color(nsColor: .quaternarySystemFill)
+ } else {
+ return Color("QuaternarySystemFillColor")
+ }
+}
+
+public var TertiarySystemFillColor: Color {
+ if #available(macOS 14.0, *) {
+ return Color(nsColor: .tertiarySystemFill)
+ } else {
+ return Color("TertiarySystemFillColor")
+ }
+}
+
+public var SecondarySystemFillColor: Color {
+ if #available(macOS 14.0, *) {
+ return Color(nsColor: .secondarySystemFill)
+ } else {
+ return Color("SecondarySystemFillColor")
+ }
}
diff --git a/Tool/Sources/SharedUIComponents/Base/FileIcon.swift b/Tool/Sources/SharedUIComponents/Base/FileIcon.swift
index 92384e17..62a647d1 100644
--- a/Tool/Sources/SharedUIComponents/Base/FileIcon.swift
+++ b/Tool/Sources/SharedUIComponents/Base/FileIcon.swift
@@ -1,26 +1,119 @@
import Foundation
import SwiftUI
-
-public func drawFileIcon(_ file: URL?) -> Image {
- let defaultImage = Image(systemName: "doc.text")
-
- guard let file = file else { return defaultImage }
+@ViewBuilder
+public func drawFileIcon(_ file: URL?) -> some View {
+ let fileExtension = file?.pathExtension.lowercased() ?? ""
- let fileExtension = file.pathExtension.lowercased()
- if fileExtension == "swift" {
+ switch fileExtension {
+ case "swift":
if let nsImage = NSImage(named: "SwiftIcon") {
- return Image(nsImage: nsImage)
+ Image(nsImage: nsImage)
+ .resizable()
+ } else {
+ Image(systemName: "doc.text")
+ .resizable()
+ }
+ case "md":
+ Text("Mβ")
+ .scaledFont(size: 10, weight: .bold, design: .monospaced)
+ .foregroundColor(.indigo)
+ case "plist":
+ Image(systemName: "table")
+ .resizable()
+ case "xcconfig":
+ Image(systemName: "gearshape.2")
+ .resizable()
+ case "html":
+ Image(systemName: "chevron.left.slash.chevron.right")
+ .resizable()
+ .foregroundColor(.blue)
+ case "entitlements":
+ Image(systemName: "checkmark.seal.text.page")
+ .resizable()
+ .foregroundColor(.yellow)
+ case "sh":
+ Image(systemName: "terminal")
+ .resizable()
+ case "txt":
+ Image(systemName: "doc.plaintext")
+ .resizable()
+ case "c", "m", "mm":
+ Text("C")
+ .scaledFont(size: 12, weight: .bold, design: .monospaced)
+ .foregroundColor(.blue)
+ case "cpp":
+ Text("C++")
+ .scaledFont(size: 8, weight: .bold, design: .monospaced)
+ .foregroundColor(.blue)
+ case "h":
+ Text("h")
+ .scaledFont(size: 12, weight: .bold, design: .monospaced)
+ .foregroundColor(.blue)
+ case "xml":
+ Text("XML")
+ .scaledFont(size: 8, weight: .bold, design: .monospaced)
+ .foregroundColor(.orange)
+ case "yml", "yaml":
+ Text("YML")
+ .scaledFont(size: 8, weight: .bold, design: .monospaced)
+ .foregroundColor(.pink)
+ case "json":
+ Text("{}")
+ .scaledFont(size: 10, weight: .bold, design: .monospaced)
+ .foregroundColor(.red)
+ case "ts":
+ Text("TS")
+ .scaledFont(size: 10, weight: .bold, design: .monospaced)
+ .foregroundColor(.blue)
+ case "tsx":
+ Text("TSX")
+ .scaledFont(size: 8, weight: .bold, design: .monospaced)
+ .foregroundColor(.blue)
+ case "js":
+ Text("JS")
+ .scaledFont(size: 10, weight: .bold, design: .monospaced)
+ .foregroundColor(.yellow)
+ case "jsx":
+ Text("JSX")
+ .scaledFont(size: 8, weight: .bold, design: .monospaced)
+ .foregroundColor(.yellow)
+ case "css":
+ Text("CSS")
+ .scaledFont(size: 8, weight: .bold, design: .monospaced)
+ .foregroundColor(.purple)
+ case "py":
+ Text("PY")
+ .scaledFont(size: 10, weight: .bold, design: .monospaced)
+ .foregroundColor(.indigo)
+ case "xctestplan":
+ ZStack {
+ Text("P")
+ .scaledFont(size: 8, weight: .bold, design: .monospaced)
+ .foregroundColor(.blue)
+ RoundedRectangle(cornerRadius: 1.5)
+ .stroke(Color.blue, lineWidth: 1.5)
+ .scaledFrame(width: 10, height: 10)
+ .rotationEffect(.degrees(45))
}
+ default:
+ Image(systemName: "doc.text")
+ .resizable()
}
-
- return defaultImage
}
-public func drawFileIcon(_ file: URL?, isDirectory: Bool = false) -> Image {
+@ViewBuilder
+public func drawFileIcon(_ file: URL?, isDirectory: Bool = false) -> some View {
if isDirectory {
- return Image(systemName: "folder")
+ if file?.lastPathComponent == "xcassets" {
+ Image(systemName: "photo.on.rectangle.angled")
+ .resizable()
+ .foregroundColor(.blue)
+ } else {
+ Image(systemName: "folder")
+ .resizable()
+ }
} else {
- return drawFileIcon(file)
+ drawFileIcon(file)
}
}
diff --git a/Tool/Sources/SharedUIComponents/Base/HoverButtunStyle.swift b/Tool/Sources/SharedUIComponents/Base/HoverButtunStyle.swift
index 7ab6bd56..313346ad 100644
--- a/Tool/Sources/SharedUIComponents/Base/HoverButtunStyle.swift
+++ b/Tool/Sources/SharedUIComponents/Base/HoverButtunStyle.swift
@@ -5,11 +5,21 @@ public struct HoverButtonStyle: ButtonStyle {
@State private var isHovered: Bool
private var padding: CGFloat
private var hoverColor: Color
+ private var backgroundColor: Color
+ private var cornerRadius: CGFloat
- public init(isHovered: Bool = false, padding: CGFloat = 4, hoverColor: Color = .hoverColor) {
+ public init(
+ isHovered: Bool = false,
+ padding: CGFloat = 4,
+ hoverColor: Color = .hoverColor,
+ backgroundColor: Color = .clear,
+ cornerRadius: CGFloat = 4
+ ) {
self.isHovered = isHovered
self.padding = padding
self.hoverColor = hoverColor
+ self.backgroundColor = backgroundColor
+ self.cornerRadius = cornerRadius
}
public func makeBody(configuration: Configuration) -> some View {
@@ -20,9 +30,9 @@ public struct HoverButtonStyle: ButtonStyle {
? Color.gray.opacity(0.2)
: isHovered
? hoverColor
- : Color.clear
+ : backgroundColor
)
- .cornerRadius(4)
+ .cornerRadius(cornerRadius)
.onHover { hover in
isHovered = hover
}
diff --git a/Tool/Sources/SharedUIComponents/CollapsibleSearchField.swift b/Tool/Sources/SharedUIComponents/CollapsibleSearchField.swift
new file mode 100644
index 00000000..54edfe08
--- /dev/null
+++ b/Tool/Sources/SharedUIComponents/CollapsibleSearchField.swift
@@ -0,0 +1,118 @@
+import SwiftUI
+import AppKit
+
+public struct CollapsibleSearchField: View {
+ @Binding public var searchText: String
+ @Binding public var isExpanded: Bool
+ public let placeholderString: String
+
+ public init(
+ searchText: Binding,
+ isExpanded: Binding,
+ placeholderString: String = "Search..."
+ ) {
+ self._searchText = searchText
+ self._isExpanded = isExpanded
+ self.placeholderString = placeholderString
+ }
+
+ public var body: some View {
+ Group {
+ if isExpanded {
+ SearchFieldRepresentable(
+ searchText: $searchText,
+ isExpanded: $isExpanded,
+ placeholderString: placeholderString
+ )
+ .frame(width: 200, height: 24)
+ .transition(.opacity)
+ } else {
+ Button(action: {
+ isExpanded = true
+ }) {
+ Image(systemName: "magnifyingglass")
+ .font(.system(size: 13))
+ }
+ .buttonStyle(.plain)
+ .frame(height: 24)
+ .transition(.opacity)
+ }
+ }
+ }
+}
+
+private struct SearchFieldRepresentable: NSViewRepresentable {
+ @Binding var searchText: String
+ @Binding var isExpanded: Bool
+ let placeholderString: String
+
+ func makeNSView(context: Context) -> NSSearchField {
+ let searchField = NSSearchField()
+ searchField.placeholderString = placeholderString
+ searchField.delegate = context.coordinator
+ searchField.target = context.coordinator
+ searchField.action = #selector(Coordinator.searchFieldDidChange(_:))
+
+ // Make the magnifying glass clickable to collapse
+ if let cell = searchField.cell as? NSSearchFieldCell {
+ cell.searchButtonCell?.target = context.coordinator
+ cell.searchButtonCell?.action = #selector(Coordinator.magnifyingGlassClicked(_:))
+ }
+
+ return searchField
+ }
+
+ func updateNSView(_ nsView: NSSearchField, context: Context) {
+ if nsView.stringValue != searchText {
+ nsView.stringValue = searchText
+ }
+
+ context.coordinator.isExpanded = $isExpanded
+
+ // Auto-focus when expanded, only if not already first responder
+ if isExpanded && nsView.window?.firstResponder != nsView.currentEditor() {
+ DispatchQueue.main.async {
+ nsView.window?.makeFirstResponder(nsView)
+ }
+ }
+ }
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator(searchText: $searchText, isExpanded: $isExpanded)
+ }
+
+ class Coordinator: NSObject, NSSearchFieldDelegate, NSTextFieldDelegate {
+ @Binding var searchText: String
+ var isExpanded: Binding
+
+ init(searchText: Binding, isExpanded: Binding) {
+ _searchText = searchText
+ self.isExpanded = isExpanded
+ }
+
+ @objc func searchFieldDidChange(_ sender: NSSearchField) {
+ searchText = sender.stringValue
+ }
+
+ @objc func magnifyingGlassClicked(_ sender: Any) {
+ // Collapse when magnifying glass is clicked
+ DispatchQueue.main.async { [weak self] in
+ withAnimation(.easeInOut(duration: 0.2)) {
+ self?.isExpanded.wrappedValue = false
+ }
+ }
+ }
+
+ func controlTextDidEndEditing(_ obj: Notification) {
+ // Collapse search field when it loses focus and text is empty
+ if searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ DispatchQueue.main.async { [weak self] in
+ withAnimation(.easeInOut(duration: 0.2)) {
+ self?.isExpanded.wrappedValue = false
+ self?.searchText = ""
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/Tool/Sources/SharedUIComponents/ConditionalFontWeight.swift b/Tool/Sources/SharedUIComponents/ConditionalFontWeight.swift
index 55cc15c7..b09c94ba 100644
--- a/Tool/Sources/SharedUIComponents/ConditionalFontWeight.swift
+++ b/Tool/Sources/SharedUIComponents/ConditionalFontWeight.swift
@@ -8,11 +8,7 @@ public struct ConditionalFontWeight: ViewModifier {
}
public func body(content: Content) -> some View {
- if #available(macOS 13.0, *), weight != nil {
- content.fontWeight(weight)
- } else {
- content
- }
+ content.fontWeight(weight)
}
}
diff --git a/Core/Sources/HostApp/AdvancedSettings/CreateCustomCopilotFileView.swift b/Tool/Sources/SharedUIComponents/CreateCustomCopilotFileView.swift
similarity index 56%
rename from Core/Sources/HostApp/AdvancedSettings/CreateCustomCopilotFileView.swift
rename to Tool/Sources/SharedUIComponents/CreateCustomCopilotFileView.swift
index d3b45f07..2758a5cf 100644
--- a/Core/Sources/HostApp/AdvancedSettings/CreateCustomCopilotFileView.swift
+++ b/Tool/Sources/SharedUIComponents/CreateCustomCopilotFileView.swift
@@ -1,79 +1,78 @@
-import Client
import SwiftUI
-import XcodeInspector
+import ConversationServiceProvider
+import AppKitExtension
-struct CreateCustomCopilotFileView: View {
- var isOpen: Binding
- let promptType: PromptType
+public struct CreateCustomCopilotFileView: View {
+ public let promptType: PromptType
+ public let editorPluginVersion: String
+ public let getCurrentProjectURL: () async -> URL?
+ public let onSuccess: (String) -> Void
+ public let onError: (String) -> Void
@State private var fileName = ""
@State private var projectURL: URL?
@State private var fileAlreadyExists = false
- @Environment(\.toast) var toast
+ @Environment(\.dismiss) private var dismiss
- init(isOpen: Binding, promptType: PromptType) {
- self.isOpen = isOpen
+ public init(
+ promptType: PromptType,
+ editorPluginVersion: String,
+ getCurrentProjectURL: @escaping () async -> URL?,
+ onSuccess: @escaping (String) -> Void,
+ onError: @escaping (String) -> Void
+ ) {
self.promptType = promptType
+ self.editorPluginVersion = editorPluginVersion
+ self.getCurrentProjectURL = getCurrentProjectURL
+ self.onSuccess = onSuccess
+ self.onError = onError
}
- var body: some View {
- VStack(alignment: .leading, spacing: 0) {
- HStack(alignment: .center) {
- Button(action: { self.isOpen.wrappedValue = false }) {
- Image(systemName: "xmark.circle.fill")
- .foregroundStyle(.secondary)
- .padding()
+ public var body: some View {
+ Form {
+ VStack(alignment: .center, spacing: 20) {
+ HStack(alignment: .center) {
+ Spacer()
+ Text("Create \(promptType.displayName)").font(.headline)
+ Spacer()
+ AdaptiveHelpLink(action: openHelpLink)
}
- .buttonStyle(.plain)
- Text("Create \(promptType.displayName)")
- .font(.system(size: 13, weight: .bold))
- Spacer()
-
- AdaptiveHelpLink(action: openHelpLink)
- .padding()
- }
- .frame(height: 28)
- .background(Color(nsColor: .separatorColor))
-
- // Content
- VStack(alignment: .leading, spacing: 8) {
- Text("Enter the name of \(promptType.rawValue) file:")
- .font(.body)
-
- TextField("File name", text: $fileName)
- .textFieldStyle(.roundedBorder)
- .onSubmit {
- Task { await createPromptFile() }
+ // Content
+ VStack(alignment: .leading, spacing: 4) {
+ TextFieldsContainer {
+ TextField("File name", text: Binding(
+ get: { fileName },
+ set: { newValue in
+ fileName = newValue
+ updateFileExistence()
+ }
+ ))
+ .disableAutocorrection(true)
+ .textContentType(.none)
+ .onSubmit {
+ Task { await createPromptFile() }
+ }
}
- .onChange(of: fileName) { _ in
- updateFileExistence()
- }
-
- validationMessageView
- Spacer()
+ validationMessageView
+ }
- HStack(spacing: 12) {
+ HStack(spacing: 8) {
Spacer()
-
- Button("Cancel") {
- self.isOpen.wrappedValue = false
- }
- .buttonStyle(.bordered)
-
- Button("Create") {
- Task { await createPromptFile() }
- }
+ Button("Cancel", role: .cancel) { dismiss() }
+ Button("Create") { Task { await createPromptFile() } }
.buttonStyle(.borderedProminent)
.disabled(disableCreateButton)
+ .keyboardShortcut(.defaultAction)
}
}
- .padding(.vertical, 8)
- .padding(.horizontal, 20)
+ .textFieldStyle(.plain)
+ .multilineTextAlignment(.trailing)
+ .padding(20)
}
- .frame(width: 350, height: 160)
+ .frame(width: 350, height: 190)
.onAppear {
fileName = ""
Task { await resolveProjectURL() }
@@ -101,31 +100,35 @@ struct CreateCustomCopilotFileView: View {
.foregroundColor(.red)
.lineLimit(2)
.multilineTextAlignment(.leading)
+ .truncationMode(.middle)
.fixedSize(horizontal: false, vertical: true)
- .layoutPriority(1)
} else if trimmedFileName.isEmpty {
Image(systemName: "info.circle")
.foregroundColor(.secondary)
- Text("Enter a file name")
+ Text("Enter the name of \(promptType.rawValue) file")
.font(.caption)
.foregroundColor(.secondary)
} else {
+ Text("Location:")
+ .foregroundColor(.primary)
+ .padding(.leading, 10)
+ .layoutPriority(1)
Text(".github/\(promptType.directoryName)/\(trimmedFileName)\(promptType.fileExtension)")
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(2)
.multilineTextAlignment(.leading)
+ .truncationMode(.middle)
.fixedSize(horizontal: false, vertical: true)
- .layoutPriority(1)
}
}
- .transition(.opacity)
+ .padding(.horizontal, 2)
}
// MARK: - Actions / Helpers
private func openHelpLink() {
- if let url = URL(string: promptType.helpLink) {
+ if let url = URL(string: promptType.helpLink(editorPluginVersion: editorPluginVersion)) {
NSWorkspace.shared.open(url)
}
}
@@ -153,7 +156,7 @@ struct CreateCustomCopilotFileView: View {
private func createPromptFile() async {
guard let projectURL else {
await MainActor.run {
- toast("No active workspace found", .error)
+ onError("No active workspace found")
}
return
}
@@ -165,7 +168,7 @@ struct CreateCustomCopilotFileView: View {
if FileManager.default.fileExists(atPath: filePath.path) {
await MainActor.run {
self.fileAlreadyExists = true
- toast("\(promptType.displayName) '\(trimmedFileName)\(promptType.fileExtension)' already exists", .warning)
+ onError("\(promptType.displayName) '\(trimmedFileName)\(promptType.fileExtension)' already exists")
}
return
}
@@ -179,13 +182,13 @@ struct CreateCustomCopilotFileView: View {
try promptType.defaultTemplate.write(to: filePath, atomically: true, encoding: .utf8)
await MainActor.run {
- toast("Created \(promptType.rawValue) file '\(trimmedFileName)\(promptType.fileExtension)'", .info)
- NSWorkspace.shared.open(filePath)
- self.isOpen.wrappedValue = false
+ onSuccess("Created \(promptType.rawValue) file '\(trimmedFileName)\(promptType.fileExtension)'")
+ NSWorkspace.openFileInXcode(fileURL: filePath)
+ dismiss()
}
} catch {
await MainActor.run {
- toast("Failed to create \(promptType.rawValue) file: \(error)", .error)
+ onError("Failed to create \(promptType.rawValue) file: \(error)")
}
}
}
diff --git a/Tool/Sources/SharedUIComponents/CustomScrollView.swift b/Tool/Sources/SharedUIComponents/CustomScrollView.swift
index 0eb486f0..91c73fae 100644
--- a/Tool/Sources/SharedUIComponents/CustomScrollView.swift
+++ b/Tool/Sources/SharedUIComponents/CustomScrollView.swift
@@ -44,11 +44,7 @@ public struct CustomScrollView: View {
}
.listStyle(.plain)
.modify { view in
- if #available(macOS 13.0, *) {
- view.listRowSeparator(.hidden).listSectionSeparator(.hidden)
- } else {
- view
- }
+ view.listRowSeparator(.hidden).listSectionSeparator(.hidden)
}
.frame(idealHeight: max(10, height))
.onPreferenceChange(CustomScrollViewHeightPreferenceKey.self) { newHeight in
diff --git a/Tool/Sources/SharedUIComponents/DestructiveButtonStyle.swift b/Tool/Sources/SharedUIComponents/DestructiveButtonStyle.swift
new file mode 100644
index 00000000..3896b01f
--- /dev/null
+++ b/Tool/Sources/SharedUIComponents/DestructiveButtonStyle.swift
@@ -0,0 +1,20 @@
+import SwiftUI
+
+public struct DestructiveButtonStyle: ButtonStyle {
+ public init() {}
+ public func makeBody(configuration: Configuration) -> some View {
+ configuration.label
+ .font(.system(size: 13, weight: .medium))
+ .foregroundColor(Color.red)
+ .padding(.horizontal, 13)
+ .padding(.vertical, 4)
+ .background(
+ RoundedRectangle(cornerRadius: 6)
+ .fill(Color.red.opacity(0.25))
+ .overlay(
+ RoundedRectangle(cornerRadius: 6)
+ .fill(Color.black.opacity(configuration.isPressed ? 0.15 : 0))
+ )
+ )
+ }
+}
diff --git a/Tool/Sources/SharedUIComponents/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/OverlayScrollView.swift b/Tool/Sources/SharedUIComponents/OverlayScrollView.swift
new file mode 100644
index 00000000..751d3d4f
--- /dev/null
+++ b/Tool/Sources/SharedUIComponents/OverlayScrollView.swift
@@ -0,0 +1,45 @@
+import SwiftUI
+import AppKit
+
+public struct OverlayScrollView: NSViewRepresentable {
+ let showsVerticalScroller: Bool
+ let showsHorizontalScroller: Bool
+ let content: Content
+
+ public init(showsVerticalScroller: Bool = true,
+ showsHorizontalScroller: Bool = false,
+ @ViewBuilder content: () -> Content) {
+ self.showsVerticalScroller = showsVerticalScroller
+ self.showsHorizontalScroller = showsHorizontalScroller
+ self.content = content()
+ }
+
+ public func makeNSView(context: Context) -> NSScrollView {
+ let scrollView = NSScrollView()
+ scrollView.drawsBackground = false
+ scrollView.hasVerticalScroller = showsVerticalScroller
+ scrollView.hasHorizontalScroller = showsHorizontalScroller
+ scrollView.autohidesScrollers = true
+ scrollView.scrollerStyle = .overlay
+ scrollView.verticalScrollElasticity = .automatic
+ scrollView.horizontalScrollElasticity = .automatic
+
+ let hosting = NSHostingView(rootView: content)
+ hosting.translatesAutoresizingMaskIntoConstraints = false
+
+ scrollView.documentView = hosting
+
+ if let docView = scrollView.contentView.documentView {
+ docView.leadingAnchor.constraint(equalTo: scrollView.contentView.leadingAnchor).isActive = true
+ docView.trailingAnchor.constraint(equalTo: scrollView.contentView.trailingAnchor).isActive = true
+ docView.topAnchor.constraint(equalTo: scrollView.contentView.topAnchor).isActive = true
+ }
+ return scrollView
+ }
+
+ public func updateNSView(_ nsView: NSScrollView, context: Context) {
+ if let hosting = nsView.documentView as? NSHostingView {
+ hosting.rootView = content
+ }
+ }
+}
diff --git a/Tool/Sources/SharedUIComponents/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/Core/Sources/HostApp/SharedComponents/TextFieldsContainer.swift b/Tool/Sources/SharedUIComponents/TextFieldsContainer.swift
similarity index 78%
rename from Core/Sources/HostApp/SharedComponents/TextFieldsContainer.swift
rename to Tool/Sources/SharedUIComponents/TextFieldsContainer.swift
index 6cf592f3..b4c9bcc9 100644
--- a/Core/Sources/HostApp/SharedComponents/TextFieldsContainer.swift
+++ b/Tool/Sources/SharedUIComponents/TextFieldsContainer.swift
@@ -1,13 +1,13 @@
import SwiftUI
-struct TextFieldsContainer: View {
+public struct TextFieldsContainer: View {
let content: Content
- init(@ViewBuilder content: () -> Content) {
+ public init(@ViewBuilder content: () -> Content) {
self.content = content()
}
- var body: some View {
+ public var body: some View {
VStack(spacing: 8) {
content
}
diff --git a/Tool/Sources/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 f1b2d1d3..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()
}
@@ -68,7 +45,6 @@ public class QuotaView: NSView {
autoresizingMask = [.width]
setupView()
- layoutSubtreeIfNeeded()
let calculatedHeight = fittingSize.height
frame = NSRect(x: 0, y: 0, width: Layout.viewWidth, height: calculatedHeight)
}
@@ -81,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)
+ }
}
}
}
@@ -166,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
@@ -216,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)
@@ -316,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
@@ -365,41 +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"
+ 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 {
+ let upgrade = buttons.isEmpty
+ ? makeProminentButton(title: "Upgrade Plan", action: #selector(openCopilotUpgradePlan))
+ : makeBorderedButton(title: "Upgrade Plan", action: #selector(openCopilotUpgradePlan))
+ buttons.append(upgrade)
+ }
+ }
+ 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"
- button.translatesAutoresizingMaskIntoConstraints = false
- button.bezelStyle = .push
- if isFreeQuotaUsedUp {
+ 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 {
- button.title = upgradeTitle
}
- button.controlSize = .large
- button.target = self
- button.action = #selector(openCopilotUpgradePlan)
-
- return button
} 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
}
}
@@ -412,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
}
@@ -435,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
}
}
@@ -545,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)
}
}
}
@@ -574,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/SuggestionBasic/CodeSuggestion.swift b/Tool/Sources/SuggestionBasic/CodeSuggestion.swift
index bd124fc1..e910af17 100644
--- a/Tool/Sources/SuggestionBasic/CodeSuggestion.swift
+++ b/Tool/Sources/SuggestionBasic/CodeSuggestion.swift
@@ -1,6 +1,10 @@
import Foundation
import CodableWrappers
+public enum CodeSuggestionType: String {
+ case codeCompletion, nes
+}
+
public struct CodeSuggestion: Codable, Equatable {
public init(
id: String,
diff --git a/Tool/Sources/SuggestionProvider/NESSuggestionServiceType.swift b/Tool/Sources/SuggestionProvider/NESSuggestionServiceType.swift
new file mode 100644
index 00000000..c088a688
--- /dev/null
+++ b/Tool/Sources/SuggestionProvider/NESSuggestionServiceType.swift
@@ -0,0 +1,9 @@
+import CopilotForXcodeKit
+
+public protocol NESSuggestionServiceType {
+ func getNESSuggestions(
+ _ request: CopilotForXcodeKit.SuggestionRequest,
+ workspace: CopilotForXcodeKit.WorkspaceInfo
+ ) async throws -> [CopilotForXcodeKit.CodeSuggestion]
+}
+
diff --git a/Tool/Sources/SuggestionProvider/PostProcessingSuggestionServiceMiddleware.swift b/Tool/Sources/SuggestionProvider/PostProcessingSuggestionServiceMiddleware.swift
index e69e29d2..3a60489a 100644
--- a/Tool/Sources/SuggestionProvider/PostProcessingSuggestionServiceMiddleware.swift
+++ b/Tool/Sources/SuggestionProvider/PostProcessingSuggestionServiceMiddleware.swift
@@ -19,6 +19,23 @@ public struct PostProcessingSuggestionServiceMiddleware: SuggestionServiceMiddle
return suggestion
}
}
+
+ public func getNESSuggestion(
+ _ request: SuggestionRequest,
+ configuration: SuggestionServiceConfiguration,
+ next: Next
+ ) async throws -> [CodeSuggestion] {
+ let suggestions = try await next(request)
+
+ return suggestions.compactMap {
+ var suggestion = $0
+ if suggestion.text.allSatisfy({ $0.isWhitespace || $0.isNewline }) { return nil }
+ Self.removeTrailingWhitespacesAndNewlines(&suggestion)
+ // TODO: If need to check?
+ // if !Self.checkIfSuggestionHasNoEffect(suggestion, request: request) { return nil }
+ return suggestion
+ }
+ }
static func removeTrailingWhitespacesAndNewlines(_ suggestion: inout CodeSuggestion) {
var text = suggestion.text[...]
diff --git a/Tool/Sources/SuggestionProvider/SuggestionServiceMiddleware.swift b/Tool/Sources/SuggestionProvider/SuggestionServiceMiddleware.swift
index dcbfba5e..1bec7d30 100644
--- a/Tool/Sources/SuggestionProvider/SuggestionServiceMiddleware.swift
+++ b/Tool/Sources/SuggestionProvider/SuggestionServiceMiddleware.swift
@@ -10,6 +10,12 @@ public protocol SuggestionServiceMiddleware {
configuration: SuggestionServiceConfiguration,
next: Next
) async throws -> [CodeSuggestion]
+
+ func getNESSuggestion(
+ _ request: SuggestionRequest,
+ configuration: SuggestionServiceConfiguration,
+ next: Next
+ ) async throws -> [CodeSuggestion]
}
public enum SuggestionServiceMiddlewareContainer {
@@ -49,6 +55,24 @@ public struct DisabledLanguageSuggestionServiceMiddleware: SuggestionServiceMidd
return try await next(request)
}
+
+ public func getNESSuggestion(
+ _ request: SuggestionRequest,
+ configuration: SuggestionServiceConfiguration,
+ next: Next
+ ) async throws -> [CodeSuggestion] {
+ let language = languageIdentifierFromFileURL(request.fileURL)
+ if UserDefaults.shared.value(for: \.suggestionFeatureDisabledLanguageList)
+ .contains(where: { $0 == language.rawValue })
+ {
+ #if DEBUG
+ Logger.service.info("Suggestion service is disabled for \(language).")
+ #endif
+ return []
+ }
+
+ return try await next(request)
+ }
}
public struct DebugSuggestionServiceMiddleware: SuggestionServiceMiddleware {
@@ -76,5 +100,28 @@ public struct DebugSuggestionServiceMiddleware: SuggestionServiceMiddleware {
throw error
}
}
+
+ public func getNESSuggestion(
+ _ request: SuggestionRequest,
+ configuration: SuggestionServiceConfiguration,
+ next: Next
+ ) async throws -> [CodeSuggestion] {
+ Logger.service.info("""
+ Get suggestion for \(request.fileURL) at \(request.cursorPosition)
+ """)
+ do {
+ let suggestions = try await next(request)
+ Logger.service.info("""
+ Receive \(suggestions.count) suggestions for \(request.fileURL) \
+ at \(request.cursorPosition)
+ """)
+ return suggestions
+ } catch {
+ Logger.service.info("""
+ Error: \(error.localizedDescription)
+ """)
+ throw error
+ }
+ }
}
diff --git a/Tool/Sources/SuggestionProvider/SuggestionServiceProvider.swift b/Tool/Sources/SuggestionProvider/SuggestionServiceProvider.swift
index 24265613..bec85e8f 100644
--- a/Tool/Sources/SuggestionProvider/SuggestionServiceProvider.swift
+++ b/Tool/Sources/SuggestionProvider/SuggestionServiceProvider.swift
@@ -63,6 +63,10 @@ public protocol SuggestionServiceProvider {
_ request: SuggestionRequest,
workspaceInfo: CopilotForXcodeKit.WorkspaceInfo
) async throws -> [CodeSuggestion]
+ func getNESSuggestions(
+ _ request: SuggestionRequest,
+ workspaceInfo: CopilotForXcodeKit.WorkspaceInfo,
+ ) async throws -> [CodeSuggestion]
func notifyAccepted(
_ suggestion: CodeSuggestion,
workspaceInfo: CopilotForXcodeKit.WorkspaceInfo
diff --git a/Tool/Sources/SystemUtils/SystemUtils.swift b/Tool/Sources/SystemUtils/SystemUtils.swift
index 43569b88..fb785f45 100644
--- a/Tool/Sources/SystemUtils/SystemUtils.swift
+++ b/Tool/Sources/SystemUtils/SystemUtils.swift
@@ -38,6 +38,21 @@ public class SystemUtils {
public static let buildType: String = {
return shared.isDeveloperMode() ? "true" : "false"
}()
+
+ public static let isDeveloperMode: Bool = {
+ return shared.isDeveloperMode()
+ }()
+
+ public static let isPrereleaseBuild: Bool = {
+ let components = editorPluginVersionString.split(separator: ".")
+ if components.count >= 3 {
+ let patchComponent = String(components[2])
+ // If patch version is not "0"
+ return patchComponent != "0"
+ }
+
+ return false
+ }()
private init() {}
diff --git a/Tool/Sources/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/Filespace.swift b/Tool/Sources/Workspace/Filespace.swift
index 8da014a5..da77d574 100644
--- a/Tool/Sources/Workspace/Filespace.swift
+++ b/Tool/Sources/Workspace/Filespace.swift
@@ -27,6 +27,7 @@ public final class FilespacePropertyValues {
}
public struct FilespaceCodeMetadata: Equatable {
+ /// Stands for `Uniform Type Identifier`
public var uti: String?
public var tabSize: Int?
public var indentSize: Int?
@@ -66,6 +67,7 @@ public final class Filespace {
// MARK: Metadata
public let fileURL: URL
+ public private(set) var fileContent: String? = nil
public private(set) lazy var language: CodeLanguage = languageIdentifierFromFileURL(fileURL)
public var codeMetadata: FilespaceCodeMetadata = .init()
public var isTextReadable: Bool {
@@ -76,13 +78,22 @@ public final class Filespace {
public private(set) var suggestionIndex: Int = 0
public internal(set) var suggestions: [CodeSuggestion] = [] {
- didSet { refreshUpdateTime() }
+ didSet{ refreshUpdateTime() }
+ }
+ // Use Array for potential extensibility
+ public internal(set) var nesSuggestions: [CodeSuggestion] = [] {
+ didSet { refreshNESUpdateTime() }
}
public var presentingSuggestion: CodeSuggestion? {
guard suggestions.endIndex > suggestionIndex, suggestionIndex >= 0 else { return nil }
return suggestions[suggestionIndex]
}
+
+ public var presentingNESSuggestion: CodeSuggestion? {
+ // Currently, only one nes suggestion will exist there
+ return nesSuggestions.first
+ }
public private(set) var errorMessage: String = "" {
didSet { refreshUpdateTime() }
@@ -93,8 +104,13 @@ public final class Filespace {
public var isExpired: Bool {
Environment.now().timeIntervalSince(lastUpdateTime) > 60 * 3
}
+
+ public var isNESExpired: Bool {
+ Environment.now().timeIntervalSince(lastNESUpdateTime) > 60 * 3
+ }
public private(set) var lastUpdateTime: Date = Environment.now()
+ public private(set) var lastNESUpdateTime: Date = Environment.now()
private var additionalProperties = FilespacePropertyValues()
let fileSaveWatcher: FileSaveWatcher
let onClose: (URL) -> Void
@@ -110,15 +126,19 @@ public final class Filespace {
init(
fileURL: URL,
+ content: String,
onSave: @escaping (Filespace) -> Void,
onClose: @escaping (URL) -> Void
) {
self.fileURL = fileURL
+ self.fileContent = content
self.onClose = onClose
fileSaveWatcher = .init(fileURL: fileURL)
fileSaveWatcher.changeHandler = { [weak self] in
guard let self else { return }
+ // TODO: should distinguish code completion and NES?
onSave(self)
+ self.fileContent = try? String(contentsOf: self.fileURL)
}
}
@@ -135,6 +155,11 @@ public final class Filespace {
suggestions = []
suggestionIndex = 0
}
+
+ @WorkspaceActor
+ public func resetNESSuggestion() {
+ nesSuggestions = []
+ }
@WorkspaceActor
public func updateSuggestionsWithSameSelection(_ suggestions: [CodeSuggestion]) {
@@ -145,11 +170,25 @@ public final class Filespace {
public func refreshUpdateTime() {
lastUpdateTime = Environment.now()
}
+
+ public func refreshNESUpdateTime() {
+ lastNESUpdateTime = Date.now
+ }
@WorkspaceActor
public func setSuggestions(_ suggestions: [CodeSuggestion]) {
self.suggestions = suggestions
suggestionIndex = 0
+ if !self.suggestions.isEmpty {
+ self.resetNESSuggestion()
+ }
+ }
+
+ @WorkspaceActor
+ public func setNESSuggestions(_ nesSuggestions: [CodeSuggestion]) {
+ // Only when there is no code completion suggestion, NES suggestion can be set
+ guard self.suggestions.isEmpty else { return }
+ self.nesSuggestions = nesSuggestions
}
@WorkspaceActor
@@ -182,5 +221,23 @@ public final class Filespace {
public func dismissError() {
errorMessage = ""
}
+
+ @WorkspaceActor
+ public func updateCodeMetadata(
+ uti: String,
+ tabSize: Int,
+ indentSize: Int,
+ usesTabsForIndentation: Bool
+ ) {
+ self.codeMetadata.uti = uti
+ self.codeMetadata.tabSize = tabSize
+ self.codeMetadata.indentSize = indentSize
+ self.codeMetadata.usesTabsForIndentation = usesTabsForIndentation
+ }
+
+ @WorkspaceActor
+ public func setFileContent(_ content: String) {
+ fileContent = content
+ }
}
diff --git a/Tool/Sources/Workspace/Workspace.swift b/Tool/Sources/Workspace/Workspace.swift
index 82248822..9d262102 100644
--- a/Tool/Sources/Workspace/Workspace.swift
+++ b/Tool/Sources/Workspace/Workspace.swift
@@ -4,6 +4,7 @@ import UserDefaultsObserver
import XcodeInspector
import Logger
import UniformTypeIdentifiers
+import LanguageServerProtocol
enum Environment {
static var now = { Date() }
@@ -43,9 +44,9 @@ open class WorkspacePlugin {
self.workspace = workspace
}
- open func didOpenFilespace(_: Filespace) {}
+ open func didOpenFilespace(_: Filespace) async {}
open func didSaveFilespace(_: Filespace) {}
- open func didUpdateFilespace(_: Filespace, content: String) {}
+ open func didUpdateFilespace(_: Filespace, content: String, contentChanges: [TextDocumentContentChangeEvent]?) async {}
open func didCloseFilespace(_: URL) {}
}
@@ -115,7 +116,7 @@ public final class Workspace {
Task { @WorkspaceActor in
for fileURL in openedFiles {
do {
- _ = try createFilespaceIfNeeded(fileURL: fileURL)
+ _ = try await createFilespaceIfNeeded(fileURL: fileURL)
} catch _ as WorkspaceFileError {
openedFileRecoverableStorage.closeFile(fileURL: fileURL)
} catch {
@@ -130,7 +131,7 @@ public final class Workspace {
}
@WorkspaceActor
- public func createFilespaceIfNeeded(fileURL: URL) throws -> Filespace {
+ public func createFilespaceIfNeeded(fileURL: URL) async throws -> Filespace {
let extensionName = fileURL.pathExtension
if ["xcworkspace", "xcodeproj"].contains(
@@ -149,9 +150,12 @@ public final class Workspace {
throw WorkspaceFileError.invalidFileFormat(fileURL: fileURL)
}
+ let content = try String(contentsOf: fileURL)
+
let existedFilespace = filespaces[fileURL]
let filespace = existedFilespace ?? .init(
fileURL: fileURL,
+ content: content,
onSave: { [weak self] filespace in
guard let self else { return }
self.didSaveFilespace(filespace)
@@ -165,7 +169,7 @@ public final class Workspace {
filespaces[fileURL] = filespace
}
if existedFilespace == nil {
- didOpenFilespace(filespace)
+ await didOpenFilespace(filespace)
} else {
filespace.refreshUpdateTime()
}
@@ -178,22 +182,38 @@ public final class Workspace {
}
@WorkspaceActor
- public func didUpdateFilespace(fileURL: URL, content: String) {
+ public func didUpdateFilespace(fileURL: URL, content: String) async {
refreshUpdateTime()
guard let filespace = filespaces[fileURL] else { return }
filespace.bumpVersion()
filespace.refreshUpdateTime()
+
+ let oldContent = filespace.fileContent
+
+ // Calculate incremental changes if NES is enabled and we have old content
+ let changes: [TextDocumentContentChangeEvent]? = {
+ guard let oldContent = oldContent else { return nil }
+ return calculateIncrementalChanges(oldContent: oldContent, newContent: content)
+ }()
+
for plugin in plugins.values {
- plugin.didUpdateFilespace(filespace, content: content)
+ if let changes, let oldContent {
+ await plugin.didUpdateFilespace(filespace, content: oldContent, contentChanges: changes)
+ } else {
+ // fallback to full content sync
+ await plugin.didUpdateFilespace(filespace, content: content, contentChanges: nil)
+ }
}
+
+ filespace.setFileContent(content)
}
@WorkspaceActor
- func didOpenFilespace(_ filespace: Filespace) {
+ public func didOpenFilespace(_ filespace: Filespace) async {
refreshUpdateTime()
openedFileRecoverableStorage.openFile(fileURL: filespace.fileURL)
for plugin in plugins.values {
- plugin.didOpenFilespace(filespace)
+ await plugin.didOpenFilespace(filespace)
}
}
@@ -214,3 +234,137 @@ public final class Workspace {
}
}
+extension Workspace {
+ static let maxCalculationLength = 200_000
+
+ /// Calculates incremental changes between two document states.
+ /// Each change is computed on the state resulting from the previous change,
+ /// as required by the LSP specification.
+ ///
+ /// This implementation finds the common prefix and suffix, then creates
+ /// a single change event for the differing middle section. This ensures
+ /// correctness while being efficient for typical editing scenarios.
+ ///
+ /// - Parameters:
+ /// - oldContent: The original document content
+ /// - newContent: The new document content
+ /// - Returns: Array of TextDocumentContentChangeEvent in order
+ func calculateIncrementalChanges(
+ oldContent: String,
+ newContent: String
+ ) -> [TextDocumentContentChangeEvent]? {
+ // Handle identical content
+ if oldContent == newContent {
+ return nil
+ }
+
+ // Handle empty old content (new file)
+ if oldContent.isEmpty {
+ return [TextDocumentContentChangeEvent(
+ range: LSPRange(
+ start: Position(line: 0, character: 0),
+ end: Position(line: 0, character: 0)
+ ),
+ rangeLength: 0,
+ text: newContent
+ )]
+ }
+
+ // Handle empty new content (cleared file)
+ if newContent.isEmpty {
+ let endPosition = calculateEndPosition(content: oldContent)
+ return [TextDocumentContentChangeEvent(
+ range: LSPRange(
+ start: Position(line: 0, character: 0),
+ end: endPosition
+ ),
+ rangeLength: oldContent.utf16.count,
+ text: ""
+ )]
+ }
+
+ // Find common prefix
+ let oldUTF16 = Array(oldContent.utf16)
+ let newUTF16 = Array(newContent.utf16)
+ guard oldUTF16.count <= Self.maxCalculationLength,
+ newUTF16.count <= Self.maxCalculationLength else {
+ // Fallback to full replacement for very large contents
+ return nil
+ }
+
+ var prefixLength = 0
+ let minLength = min(oldUTF16.count, newUTF16.count)
+ while prefixLength < minLength && oldUTF16[prefixLength] == newUTF16[prefixLength] {
+ prefixLength += 1
+ }
+
+ // Find common suffix (after prefix)
+ var suffixLength = 0
+ while suffixLength < minLength - prefixLength &&
+ oldUTF16[oldUTF16.count - 1 - suffixLength] == newUTF16[newUTF16.count - 1 - suffixLength] {
+ suffixLength += 1
+ }
+
+ // Calculate positions
+ let startPosition = utf16OffsetToPosition(
+ content: oldContent,
+ offset: prefixLength
+ )
+
+ let endOffset = oldUTF16.count - suffixLength
+ let endPosition = utf16OffsetToPosition(
+ content: oldContent,
+ offset: endOffset
+ )
+
+ // Extract replacement text from new content
+ let newStartOffset = prefixLength
+ let newEndOffset = newUTF16.count - suffixLength
+
+ let replacementText: String
+ if newStartOffset <= newEndOffset {
+ let startIndex = newContent.utf16.index(newContent.utf16.startIndex, offsetBy: newStartOffset)
+ let endIndex = newContent.utf16.index(newContent.utf16.startIndex, offsetBy: newEndOffset)
+ replacementText = String(newContent[startIndex.. Position {
+ var line = 0
+ var character = 0
+
+ let utf16View = content.utf16
+ let safeOffset = min(offset, utf16View.count)
+ let endIndex = utf16View.index(utf16View.startIndex, offsetBy: safeOffset)
+
+ for char in utf16View[.. Position {
+ return utf16OffsetToPosition(content: content, offset: content.utf16.count)
+ }
+}
diff --git a/Tool/Sources/Workspace/WorkspaceDependency.swift b/Tool/Sources/Workspace/WorkspaceDependency.swift
new file mode 100644
index 00000000..25ad22fb
--- /dev/null
+++ b/Tool/Sources/Workspace/WorkspaceDependency.swift
@@ -0,0 +1,20 @@
+import Dependencies
+import Foundation
+
+public final class WorkspaceInvoker {
+ // Manually trigger the update of the filespace
+ public var invokeFilespaceUpdate: (URL, String) async -> Void = { _, _ in }
+
+ public init() {}
+}
+
+struct WorkspaceInvokerKey: DependencyKey {
+ static let liveValue = WorkspaceInvoker()
+}
+
+public extension DependencyValues {
+ var workspaceInvoker: WorkspaceInvoker {
+ get { self[WorkspaceInvokerKey.self] }
+ set { self[WorkspaceInvokerKey.self] = newValue }
+ }
+}
diff --git a/Tool/Sources/Workspace/WorkspacePool.swift b/Tool/Sources/Workspace/WorkspacePool.swift
index 9807702d..44468e07 100644
--- a/Tool/Sources/Workspace/WorkspacePool.swift
+++ b/Tool/Sources/Workspace/WorkspacePool.swift
@@ -67,7 +67,24 @@ public class WorkspacePool {
if filespaces.count == 1 { return filespaces.first }
Logger.workspacePool.info("Multiple workspaces found with file: \(fileURL)")
// If multiple workspaces are found, return the first with a suggestion
- return filespaces.first { $0.presentingSuggestion != nil }
+ return filespaces.first { $0.presentingSuggestion != nil } ?? filespaces.first { $0.presentingNESSuggestion != nil }
+ }
+
+ public func fetchWorkspaceAndFilespace(fileURL: URL) -> (Workspace, Filespace)? {
+ var workspace: Workspace?
+ var filespace: Filespace?
+
+ for wp in workspaces.values {
+ if let fp = wp.filespaces[fileURL] {
+ if fp.presentingSuggestion != nil || fp.presentingNESSuggestion != nil {
+ return (wp, fp)
+ }
+ workspace = wp
+ filespace = fp
+ }
+ }
+
+ return workspace.flatMap { ws in filespace.map { fs in (ws, fs) } }
}
@WorkspaceActor
@@ -93,13 +110,13 @@ public class WorkspacePool {
if let currentWorkspaceURL = await XcodeInspector.shared.safe.realtimeActiveWorkspaceURL {
if let existed = workspaces[currentWorkspaceURL] {
// Reuse the existed workspace.
- let filespace = try existed.createFilespaceIfNeeded(fileURL: fileURL)
+ let filespace = try await existed.createFilespaceIfNeeded(fileURL: fileURL)
return (existed, filespace)
}
let new = createNewWorkspace(workspaceURL: currentWorkspaceURL)
workspaces[currentWorkspaceURL] = new
- let filespace = try new.createFilespaceIfNeeded(fileURL: fileURL)
+ let filespace = try await new.createFilespaceIfNeeded(fileURL: fileURL)
return (new, filespace)
}
@@ -133,7 +150,7 @@ public class WorkspacePool {
return createNewWorkspace(workspaceURL: workspaceURL)
}()
- let filespace = try workspace.createFilespaceIfNeeded(fileURL: fileURL)
+ let filespace = try await workspace.createFilespaceIfNeeded(fileURL: fileURL)
workspaces[workspaceURL] = workspace
workspace.refreshUpdateTime()
return (workspace, filespace)
diff --git a/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift b/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift
index 47e1d9dc..d99a744f 100644
--- a/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift
+++ b/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift
@@ -4,6 +4,7 @@ import Workspace
import XPCShared
public struct FilespaceSuggestionSnapshot: Equatable {
+ public let lines: [String]
public let linesHash: Int
public let prefixLinesHash: Int
public let suffixLinesHash: Int
@@ -15,6 +16,7 @@ public struct FilespaceSuggestionSnapshot: Equatable {
return max(min(index, lines.endIndex), lines.startIndex)
}
+ self.lines = lines
self.linesHash = lines.hashValue
self.cursorPosition = cursorPosition
self.prefixLinesHash = lines[0.. FilespaceSuggestionSnapshot { .init(lines: [], cursorPosition: .outOfScope) }
}
+public struct FilespaceNESSuggestionSnapshotKey: FilespacePropertyKey {
+ public static func createDefaultValue()
+ -> FilespaceSuggestionSnapshot { .init(lines: [], cursorPosition: .outOfScope) }
+}
+
public extension FilespacePropertyValues {
@WorkspaceActor
var suggestionSourceSnapshot: FilespaceSuggestionSnapshot {
get { self[FilespaceSuggestionSnapshotKey.self] }
set { self[FilespaceSuggestionSnapshotKey.self] = newValue }
}
+
+ @WorkspaceActor
+ var nesSuggestionSourceSnapshot: FilespaceSuggestionSnapshot {
+ get { self[FilespaceNESSuggestionSnapshotKey.self] }
+ set { self[FilespaceNESSuggestionSnapshotKey.self] = newValue }
+ }
}
public extension Filespace {
@@ -53,6 +66,13 @@ public extension Filespace {
self.suggestionSourceSnapshot = FilespaceSuggestionSnapshotKey.createDefaultValue()
// swiftformat:enable all
}
+
+ @WorkspaceActor
+ func resetNESSnapshot() {
+ // swiftformat:disable redundantSelf
+ self.nesSuggestionSourceSnapshot = FilespaceNESSuggestionSnapshotKey.createDefaultValue()
+ // swiftformat:enable all
+ }
/// Validate the suggestion is still valid.
/// - Parameters:
@@ -125,6 +145,26 @@ public extension Filespace {
resetSnapshot()
return false
}
-
+
+ /// Validate the nes suggestion is still valid.
+ /// - Parameters:
+ /// - lines: lines of the file
+ /// - cursorPosition: cursor position
+ /// - Returns: `true` if the nes suggestion is still valid
+ @WorkspaceActor
+ func validateNESSuggestions(lines: [String], cursorPosition: CursorPosition) -> Bool {
+ guard presentingNESSuggestion != nil else { return false }
+
+ let updatedSnapshot = FilespaceSuggestionSnapshot(lines: lines, cursorPosition: cursorPosition)
+
+ // document state is unchanged
+ if updatedSnapshot == self.nesSuggestionSourceSnapshot {
+ return true
+ }
+
+ resetNESSuggestion()
+ resetNESSnapshot()
+ return false
+ }
}
diff --git a/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift b/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift
index e0c3f0f1..d59859bb 100644
--- a/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift
+++ b/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift
@@ -46,6 +46,52 @@ public extension Workspace {
func generateSuggestions(
forFileAt fileURL: URL,
editor: EditorContent
+ ) async throws -> [CodeSuggestion] {
+ refreshUpdateTime()
+
+ guard editor.cursorPosition != .outOfScope else {
+ throw EditorCursorOutOfScopeError()
+ }
+
+ let filespace = try await createFilespaceIfNeeded(fileURL: fileURL)
+
+ if !editor.uti.isEmpty {
+ filespace.updateCodeMetadata(
+ uti: editor.uti,
+ tabSize: editor.tabSize,
+ indentSize: editor.indentSize,
+ usesTabsForIndentation: editor.usesTabsForIndentation
+ )
+ }
+
+ filespace.codeMetadata.guessLineEnding(from: editor.lines.first)
+
+ let snapshot = FilespaceSuggestionSnapshot(content: editor)
+ filespace.suggestionSourceSnapshot = snapshot
+
+ guard let suggestionService else { throw SuggestionFeatureDisabledError() }
+ let content = editor.lines.joined(separator: "")
+ let completions = try await suggestionService.getSuggestions(
+ .from(fileURL: fileURL, content: content, editor: editor, projectRootURL: projectRootURL),
+ workspaceInfo: .init(workspaceURL: workspaceURL, projectURL: projectRootURL)
+ )
+
+ let clsStatus = await Status.shared.getCLSStatus()
+ if clsStatus.isErrorStatus && clsStatus.message.contains("Completions limit reached") {
+ filespace.setError(clsStatus.message)
+ } else {
+ filespace.setError("")
+ filespace.setSuggestions(completions)
+ }
+
+ return completions
+}
+
+ @WorkspaceActor
+ @discardableResult
+ func generateNESSuggestions(
+ forFileAt fileURL: URL,
+ editor: EditorContent
) async throws -> [CodeSuggestion] {
refreshUpdateTime()
@@ -53,48 +99,33 @@ public extension Workspace {
throw EditorCursorOutOfScopeError()
}
- let filespace = try createFilespaceIfNeeded(fileURL: fileURL)
+ let filespace = try await createFilespaceIfNeeded(fileURL: fileURL)
if !editor.uti.isEmpty {
- filespace.codeMetadata.uti = editor.uti
- filespace.codeMetadata.tabSize = editor.tabSize
- filespace.codeMetadata.indentSize = editor.indentSize
- filespace.codeMetadata.usesTabsForIndentation = editor.usesTabsForIndentation
+ filespace.updateCodeMetadata(
+ uti: editor.uti,
+ tabSize: editor.tabSize,
+ indentSize: editor.indentSize,
+ usesTabsForIndentation: editor.usesTabsForIndentation
+ )
}
filespace.codeMetadata.guessLineEnding(from: editor.lines.first)
let snapshot = FilespaceSuggestionSnapshot(content: editor)
- filespace.suggestionSourceSnapshot = snapshot
+ filespace.nesSuggestionSourceSnapshot = snapshot
guard let suggestionService else { throw SuggestionFeatureDisabledError() }
let content = editor.lines.joined(separator: "")
- let completions = try await suggestionService.getSuggestions(
- .init(
- fileURL: fileURL,
- relativePath: fileURL.path.replacingOccurrences(of: projectRootURL.path, with: ""),
- content: content,
- originalContent: content,
- lines: editor.lines,
- cursorPosition: editor.cursorPosition,
- cursorOffset: editor.cursorOffset,
- tabSize: editor.tabSize,
- indentSize: editor.indentSize,
- usesTabsForIndentation: editor.usesTabsForIndentation,
- relevantCodeSnippets: []
- ),
+ let completions = try await suggestionService.getNESSuggestions(
+ .from(fileURL: fileURL, content: content, editor: editor, projectRootURL: projectRootURL),
workspaceInfo: .init(workspaceURL: workspaceURL, projectURL: projectRootURL)
)
-
- let clsStatus = await Status.shared.getCLSStatus()
- if clsStatus.isErrorStatus && clsStatus.message.contains("Completions limit reached") {
- filespace.setError(clsStatus.message)
- } else {
- filespace.setError("")
- filespace.setSuggestions(completions)
- }
-
+
+ // TODO: How to get the `limit reached` error? Same as Code Completion?
+ filespace.setNESSuggestions(completions)
+
return completions
}
@@ -124,16 +155,27 @@ public extension Workspace {
}
}
}
+
+ @WorkspaceActor
+ func notifyNESSuggestionShown(forFileAt fileURL: URL) {
+ if let suggestion = filespaces[fileURL]?.presentingNESSuggestion {
+ Task {
+ await gitHubCopilotService?.notifyCopilotInlineEditShown(suggestion)
+ }
+ }
+ }
@WorkspaceActor
func rejectSuggestion(forFileAt fileURL: URL, editor: EditorContent?) {
refreshUpdateTime()
if let editor, !editor.uti.isEmpty {
- filespaces[fileURL]?.codeMetadata.uti = editor.uti
- filespaces[fileURL]?.codeMetadata.tabSize = editor.tabSize
- filespaces[fileURL]?.codeMetadata.indentSize = editor.indentSize
- filespaces[fileURL]?.codeMetadata.usesTabsForIndentation = editor.usesTabsForIndentation
+ filespaces[fileURL]?.updateCodeMetadata(
+ uti: editor.uti,
+ tabSize: editor.tabSize,
+ indentSize: editor.indentSize,
+ usesTabsForIndentation: editor.usesTabsForIndentation
+ )
}
Task {
@@ -147,6 +189,31 @@ public extension Workspace {
}
filespaces[fileURL]?.reset()
}
+
+ @WorkspaceActor
+ func rejectNESSuggestion(forFileAt fileURL: URL, editor: EditorContent?) {
+ refreshUpdateTime()
+
+ if let editor, !editor.uti.isEmpty {
+ filespaces[fileURL]?.updateCodeMetadata(
+ uti: editor.uti,
+ tabSize: editor.tabSize,
+ indentSize: editor.indentSize,
+ usesTabsForIndentation: editor.usesTabsForIndentation
+ )
+ }
+
+ Task {
+ await suggestionService?.notifyRejected(
+ filespaces[fileURL]?.nesSuggestions ?? [],
+ workspaceInfo: .init(
+ workspaceURL: workspaceURL,
+ projectURL: projectRootURL
+ )
+ )
+ }
+ filespaces[fileURL]?.resetNESSuggestion()
+ }
@WorkspaceActor
func acceptSuggestion(forFileAt fileURL: URL, editor: EditorContent?, suggestionLineLimit: Int? = nil) -> CodeSuggestion? {
@@ -158,10 +225,12 @@ public extension Workspace {
else { return nil }
if let editor, !editor.uti.isEmpty {
- filespaces[fileURL]?.codeMetadata.uti = editor.uti
- filespaces[fileURL]?.codeMetadata.tabSize = editor.tabSize
- filespaces[fileURL]?.codeMetadata.indentSize = editor.indentSize
- filespaces[fileURL]?.codeMetadata.usesTabsForIndentation = editor.usesTabsForIndentation
+ filespaces[fileURL]?.updateCodeMetadata(
+ uti: editor.uti,
+ tabSize: editor.tabSize,
+ indentSize: editor.indentSize,
+ usesTabsForIndentation: editor.usesTabsForIndentation
+ )
}
var allSuggestions = filespace.suggestions
@@ -184,5 +253,57 @@ public extension Workspace {
return suggestion
}
+
+ @WorkspaceActor
+ func acceptNESSuggestion(forFileAt fileURL: URL, editor: EditorContent?, suggestionLineLimit: Int? = nil) -> CodeSuggestion? {
+ refreshUpdateTime()
+ guard let filespace = filespaces[fileURL],
+ let suggestion = filespace.presentingNESSuggestion
+ else { return nil }
+
+ if let editor, !editor.uti.isEmpty {
+ filespaces[fileURL]?.updateCodeMetadata(
+ uti: editor.uti,
+ tabSize: editor.tabSize,
+ indentSize: editor.indentSize,
+ usesTabsForIndentation: editor.usesTabsForIndentation
+ )
+ }
+
+ Task {
+ await gitHubCopilotService?.notifyCopilotInlineEditAccepted(suggestion)
+ }
+
+ filespace.resetNESSuggestion()
+ filespace.resetNESSnapshot()
+
+ return suggestion
+ }
+
+ @WorkspaceActor
+ func getNESSuggestion(forFileAt fileURL: URL) -> CodeSuggestion? {
+ guard let filespace = filespaces[fileURL],
+ let suggestion = filespace.presentingNESSuggestion
+ else { return nil }
+
+ return suggestion
+ }
}
+extension SuggestionRequest {
+ static func from(fileURL: URL, content: String, editor: EditorContent, projectRootURL: URL) -> Self {
+ return .init(
+ fileURL: fileURL,
+ relativePath: fileURL.path.replacingOccurrences(of: projectRootURL.path, with: ""),
+ content: content,
+ originalContent: content,
+ lines: editor.lines,
+ cursorPosition: editor.cursorPosition,
+ cursorOffset: editor.cursorOffset,
+ tabSize: editor.tabSize,
+ indentSize: editor.indentSize,
+ usesTabsForIndentation: editor.usesTabsForIndentation,
+ relevantCodeSnippets: []
+ )
+ }
+}
diff --git a/Tool/Sources/XPCShared/XPCCommunicationBridge.swift b/Tool/Sources/XPCShared/XPCCommunicationBridge.swift
index 4b7d09cb..d54976d4 100644
--- a/Tool/Sources/XPCShared/XPCCommunicationBridge.swift
+++ b/Tool/Sources/XPCShared/XPCCommunicationBridge.swift
@@ -80,7 +80,6 @@ extension XPCCommunicationBridge {
}
}
-@available(macOS 13.0, *)
public func showBackgroundPermissionAlert() {
let alert = NSAlert()
alert.messageText = "Background Permission Required"
diff --git a/Tool/Sources/XPCShared/XPCExtensionService.swift b/Tool/Sources/XPCShared/XPCExtensionService.swift
index c48cb640..4e4d59f5 100644
--- a/Tool/Sources/XPCShared/XPCExtensionService.swift
+++ b/Tool/Sources/XPCShared/XPCExtensionService.swift
@@ -3,6 +3,7 @@ import GitHubCopilotService
import ConversationServiceProvider
import Logger
import Status
+import LanguageServerProtocol
public enum XPCExtensionServiceError: Swift.Error, LocalizedError {
case failedToGetServiceEndpoint
@@ -19,6 +20,15 @@ public enum XPCExtensionServiceError: Swift.Error, LocalizedError {
return "Connection to extension service error: \(error.localizedDescription)"
}
}
+
+ public var underlyingError: Error? {
+ switch self {
+ case let .xpcServiceError(error):
+ return error
+ default:
+ return nil
+ }
+ }
}
public class XPCExtensionService {
@@ -109,6 +119,13 @@ public class XPCExtensionService {
{ $0.getSuggestionAcceptedCode }
)
}
+
+ public func getNESSuggestionAcceptedCode(editorContent: EditorContent) async throws -> UpdatedContent? {
+ try await suggestionRequest(
+ editorContent,
+ { $0.getNESSuggestionAcceptedCode }
+ )
+ }
public func getSuggestionRejectedCode(editorContent: EditorContent) async throws
-> UpdatedContent?
@@ -118,6 +135,15 @@ public class XPCExtensionService {
{ $0.getSuggestionRejectedCode }
)
}
+
+ public func getNESSuggestionRejectedCode(editorContent: EditorContent) async throws
+ -> UpdatedContent?
+ {
+ try await suggestionRequest(
+ editorContent,
+ { $0.getNESSuggestionRejectedCode }
+ )
+ }
public func getRealtimeSuggestedCode(editorContent: EditorContent) async throws
-> UpdatedContent?
@@ -149,6 +175,19 @@ public class XPCExtensionService {
}
} as Void
}
+
+ public func toggleRealtimeNES() async throws {
+ try await withXPCServiceConnected {
+ service, continuation in
+ service.toggleRealtimeNES { error in
+ if let error {
+ continuation.reject(error)
+ return
+ }
+ continuation.resume(())
+ }
+ } as Void
+ }
public func prefetchRealtimeSuggestions(editorContent: EditorContent) async {
guard let data = try? JSONEncoder().encode(editorContent) else { return }
@@ -382,12 +421,25 @@ extension XPCExtensionService {
}
@XPCServiceActor
- public func updateMCPServerToolsStatus(_ update: [UpdateMCPToolsStatusServerCollection]) async throws {
+ public func updateMCPServerToolsStatus(
+ _ update: [UpdateMCPToolsStatusServerCollection],
+ chatAgentMode: ChatMode? = nil,
+ customChatModeId: String? = nil,
+ workspaceFolders: [WorkspaceFolder]? = nil
+ ) async throws {
return try await withXPCServiceConnected {
service, continuation in
do {
let data = try JSONEncoder().encode(update)
- service.updateMCPServerToolsStatus(tools: data)
+ let foldersData = workspaceFolders.flatMap { try? JSONEncoder().encode($0) }
+ let modeData = chatAgentMode.flatMap { try? JSONEncoder().encode($0) }
+ let modeIdData = customChatModeId.flatMap { try? JSONEncoder().encode($0) }
+ service.updateMCPServerToolsStatus(
+ tools: data,
+ chatAgentMode: modeData,
+ customChatModeId: modeIdData,
+ workspaceFolders: foldersData
+ )
continuation.resume(())
} catch {
continuation.reject(error)
@@ -457,6 +509,31 @@ extension XPCExtensionService {
}
}
+ @XPCServiceActor
+ public func getMCPRegistryAllowlist() async throws -> GetMCPRegistryAllowlistResult? {
+ return try await withXPCServiceConnected {
+ service, continuation in
+ service.getMCPRegistryAllowlist { data, error in
+ if let error {
+ continuation.reject(error)
+ return
+ }
+
+ guard let data else {
+ continuation.resume(nil)
+ return
+ }
+
+ do {
+ let response = try JSONDecoder().decode(GetMCPRegistryAllowlistResult.self, from: data)
+ continuation.resume(response)
+ } catch {
+ continuation.reject(error)
+ }
+ }
+ }
+ }
+
@XPCServiceActor
public func getAvailableLanguageModelTools() async throws -> [LanguageModelTool]? {
return try await withXPCServiceConnected {
@@ -478,12 +555,45 @@ extension XPCExtensionService {
}
@XPCServiceActor
- public func updateToolsStatus(_ update: [ToolStatusUpdate]) async throws -> [LanguageModelTool]? {
+ public func refreshClientTools() async throws -> [LanguageModelTool]? {
+ return try await withXPCServiceConnected {
+ service, continuation in
+ service.refreshClientTools { data in
+ guard let data else {
+ continuation.resume(nil)
+ return
+ }
+
+ do {
+ let tools = try JSONDecoder().decode([LanguageModelTool].self, from: data)
+ continuation.resume(tools)
+ } catch {
+ continuation.reject(error)
+ }
+ }
+ }
+ }
+
+ @XPCServiceActor
+ public func updateToolsStatus(
+ _ update: [ToolStatusUpdate],
+ chatAgentMode: ChatMode? = nil,
+ customChatModeId: String? = nil,
+ workspaceFolders: [WorkspaceFolder]? = nil
+ ) async throws -> [LanguageModelTool]? {
return try await withXPCServiceConnected {
service, continuation in
do {
let data = try JSONEncoder().encode(update)
- service.updateToolsStatus(tools: data) { data in
+ let foldersData = workspaceFolders.flatMap { try? JSONEncoder().encode($0) }
+ let modeData = chatAgentMode.flatMap { try? JSONEncoder().encode($0) }
+ let modeIdData = customChatModeId.flatMap { try? JSONEncoder().encode($0) }
+ service.updateToolsStatus(
+ tools: data,
+ chatAgentMode: modeData,
+ customChatModeId: modeIdData,
+ workspaceFolders: foldersData
+ ) { data in
guard let data else {
continuation.resume(nil)
return
@@ -522,6 +632,52 @@ extension XPCExtensionService {
}
}
+ @XPCServiceActor
+ public func getCopilotPolicy() async throws -> CopilotPolicy? {
+ return try await withXPCServiceConnected {
+ service, continuation in
+ service.getCopilotPolicy { data in
+ guard let data else {
+ continuation.resume(nil)
+ return
+ }
+
+ do {
+ let copilotPolicy = try JSONDecoder().decode(CopilotPolicy.self, from: data)
+ continuation.resume(copilotPolicy)
+ } catch {
+ continuation.reject(error)
+ }
+ }
+ }
+ }
+
+ @XPCServiceActor
+ public func getModes(workspaceFolders: [WorkspaceFolder]? = nil) async throws -> [ConversationMode]? {
+ return try await withXPCServiceConnected {
+ service, continuation in
+ let workspaceFoldersData = workspaceFolders.flatMap { try? JSONEncoder().encode($0) }
+ service.getModes(workspaceFolders: workspaceFoldersData) { data, error in
+ if let error {
+ continuation.reject(error)
+ return
+ }
+
+ guard let data else {
+ continuation.resume(nil)
+ return
+ }
+
+ do {
+ let modes = try JSONDecoder().decode([ConversationMode].self, from: data)
+ continuation.resume(modes)
+ } catch {
+ continuation.reject(error)
+ }
+ }
+ }
+ }
+
@XPCServiceActor
public func signOutAllGitHubCopilotService() async throws {
return try await withXPCServiceConnected {
@@ -549,6 +705,31 @@ extension XPCExtensionService {
}
}
+ @XPCServiceActor
+ public func updateCopilotModels() async throws -> [CopilotModel]? {
+ return try await withXPCServiceConnected {
+ service, continuation in
+ service.updateCopilotModels { data, error in
+ if let error {
+ continuation.reject(error)
+ return
+ }
+
+ guard let data else {
+ continuation.resume(nil)
+ return
+ }
+
+ do {
+ let models = try JSONDecoder().decode([CopilotModel].self, from: data)
+ continuation.resume(models)
+ } catch {
+ continuation.reject(error)
+ }
+ }
+ }
+ }
+
// MARK: BYOK
@XPCServiceActor
public func saveBYOKApiKey(_ params: BYOKSaveApiKeyParams) async throws -> BYOKSaveApiKeyResponse? {
diff --git a/Tool/Sources/XPCShared/XPCServiceProtocol.swift b/Tool/Sources/XPCShared/XPCServiceProtocol.swift
index 1ffddc10..4489233f 100644
--- a/Tool/Sources/XPCShared/XPCServiceProtocol.swift
+++ b/Tool/Sources/XPCShared/XPCServiceProtocol.swift
@@ -8,13 +8,17 @@ public protocol XPCServiceProtocol {
func getNextSuggestedCode(editorContent: Data, withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void)
func getPreviousSuggestedCode(editorContent: Data, withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void)
func getSuggestionAcceptedCode(editorContent: Data, withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void)
+ func getNESSuggestionAcceptedCode(editorContent: Data, withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void)
func getSuggestionRejectedCode(editorContent: Data, withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void)
+ func getNESSuggestionRejectedCode(editorContent: Data, withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void)
func getRealtimeSuggestedCode(editorContent: Data, withReply reply: @escaping (Data?, Error?) -> Void)
func getPromptToCodeAcceptedCode(editorContent: Data, withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void)
func openChat(withReply reply: @escaping (Error?) -> Void)
func promptToCode(editorContent: Data, withReply reply: @escaping (Data?, Error?) -> Void)
func customCommand(id: String, editorContent: Data, withReply reply: @escaping (Data?, Error?) -> Void)
+
func toggleRealtimeSuggestion(withReply reply: @escaping (Error?) -> Void)
+ func toggleRealtimeNES(withReply reply: @escaping (Error?) -> Void)
func prefetchRealtimeSuggestions(editorContent: Data, withReply reply: @escaping () -> Void)
func getXPCServiceVersion(withReply reply: @escaping (String, String) -> Void)
@@ -24,13 +28,29 @@ public protocol XPCServiceProtocol {
func getXcodeInspectorData(withReply reply: @escaping (Data?, Error?) -> Void)
func getAvailableMCPServerToolsCollections(withReply reply: @escaping (Data?) -> Void)
- func updateMCPServerToolsStatus(tools: Data)
+ func updateMCPServerToolsStatus(
+ tools: Data,
+ chatAgentMode: Data?,
+ customChatModeId: Data?,
+ workspaceFolders: Data?
+ )
func listMCPRegistryServers(_ params: Data, withReply reply: @escaping (Data?, Error?) -> Void)
func getMCPRegistryServer(_ params: Data, withReply reply: @escaping (Data?, Error?) -> Void)
+ func getMCPRegistryAllowlist(withReply reply: @escaping (Data?, Error?) -> Void)
func getAvailableLanguageModelTools(withReply reply: @escaping (Data?) -> Void)
- func updateToolsStatus(tools: Data, withReply reply: @escaping (Data?) -> Void)
+ func refreshClientTools(withReply reply: @escaping (Data?) -> Void)
+ func updateToolsStatus(
+ tools: Data,
+ chatAgentMode: Data?,
+ customChatModeId: Data?,
+ workspaceFolders: Data?,
+ withReply reply: @escaping (Data?) -> Void
+ )
func getCopilotFeatureFlags(withReply reply: @escaping (Data?) -> Void)
+ func getCopilotPolicy(withReply reply: @escaping (Data?) -> Void)
+ func updateCopilotModels(withReply reply: @escaping (Data?, Error?) -> Void)
+ func getModes(workspaceFolders: Data?, withReply reply: @escaping (Data?, Error?) -> Void)
func signOutAllGitHubCopilotService()
func getXPCServiceAuthStatus(withReply reply: @escaping (Data?) -> Void)
diff --git a/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift
index c1d1b415..1b4fbdf3 100644
--- a/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift
+++ b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift
@@ -447,7 +447,7 @@ public extension AXUIElement {
if element.identifier == "editor context" {
return .skipDescendantsAndSiblings
}
- if element.isSourceEditor {
+ if element.isNonNavigatorSourceEditor {
return .skipDescendantsAndSiblings
}
if description == "Code Coverage Ribbon" {
diff --git a/Tool/Sources/XcodeInspector/Helpers.swift b/Tool/Sources/XcodeInspector/Helpers.swift
index 3899412b..a5355be5 100644
--- a/Tool/Sources/XcodeInspector/Helpers.swift
+++ b/Tool/Sources/XcodeInspector/Helpers.swift
@@ -17,7 +17,7 @@ public extension FileManager {
}
extension AXUIElement {
- var realtimeDocumentURL: URL? {
+ public var realtimeDocumentURL: URL? {
guard let window = self.focusedWindow,
window.identifier == "Xcode.WorkspaceWindow"
else { return nil }
diff --git a/Tool/Sources/XcodeInspector/XcodeInspector.swift b/Tool/Sources/XcodeInspector/XcodeInspector.swift
index bb5c3cf9..8c93c60b 100644
--- a/Tool/Sources/XcodeInspector/XcodeInspector.swift
+++ b/Tool/Sources/XcodeInspector/XcodeInspector.swift
@@ -292,13 +292,13 @@ public final class XcodeInspector: ObservableObject {
focusedElement = xcode.getFocusedElement(shouldRecordStatus: true)
- if let editorElement = focusedElement, editorElement.isSourceEditor {
+ if let editorElement = focusedElement, editorElement.isNonNavigatorSourceEditor {
focusedEditor = .init(
runningApplication: xcode.runningApplication,
element: editorElement
)
} else if let element = focusedElement,
- let editorElement = element.firstParent(where: \.isSourceEditor)
+ let editorElement = element.firstParent(where: \.isNonNavigatorSourceEditor)
{
focusedEditor = .init(
runningApplication: xcode.runningApplication,
@@ -326,15 +326,13 @@ public final class XcodeInspector: ObservableObject {
.value(for: \.restartXcodeInspectorIfAccessibilityAPIIsMalfunctioning)
{
let malfunctionCheck = Task { @XcodeInspectorActor [weak self] in
- if #available(macOS 13.0, *) {
- let notifications = await xcode.axNotifications.notifications().filter {
- $0.kind == .uiElementDestroyed
- }.debounce(for: .milliseconds(1000))
- for await _ in notifications {
- guard let self else { return }
- try Task.checkCancellation()
- self.checkForAccessibilityMalfunction("Element Destroyed")
- }
+ let notifications = await xcode.axNotifications.notifications().filter {
+ $0.kind == .uiElementDestroyed
+ }.debounce(for: .milliseconds(1000))
+ for await _ in notifications {
+ guard let self else { return }
+ try Task.checkCancellation()
+ self.checkForAccessibilityMalfunction("Element Destroyed")
}
}
@@ -374,7 +372,7 @@ public final class XcodeInspector: ObservableObject {
guard Date().timeIntervalSince(lastRecoveryFromAccessibilityMalfunctioningTimeStamp) > 5
else { return }
- if let editor = focusedEditor, !editor.element.isSourceEditor {
+ if let editor = focusedEditor, !editor.element.isNonNavigatorSourceEditor {
NotificationCenter.default.post(
name: .accessibilityAPIMalfunctioning,
object: "Source Editor Element Corrupted: \(source)"
diff --git a/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift b/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift
index d2506822..1f767be6 100644
--- a/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift
+++ b/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift
@@ -111,6 +111,72 @@ public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector {
return url
}
}
+
+ // Fallback: If no child has the workspace path in description,
+ // try to derive it from the window's document URL
+ if let documentURL = extractDocumentURL(windowElement: windowElement) {
+ if let workspaceURL = deriveWorkspaceFromDocumentURL(documentURL) {
+ return workspaceURL
+ }
+ }
+
+ return nil
+ }
+
+ static func deriveWorkspaceFromDocumentURL(_ documentURL: URL) -> URL? {
+ // Check if documentURL itself is already a workspace/project/playground
+ if documentURL.pathExtension == "xcworkspace" ||
+ documentURL.pathExtension == "xcodeproj" ||
+ documentURL.pathExtension == "playground" {
+ return documentURL
+ }
+
+ // Try to find .xcodeproj or .xcworkspace in parent directories
+ var currentURL = documentURL
+ while currentURL.pathComponents.count > 1 {
+ currentURL.deleteLastPathComponent()
+
+ // Check if current directory is a playground
+ if currentURL.pathExtension == "playground" {
+ return currentURL
+ }
+
+ // Check if this directory contains .xcodeproj or .xcworkspace
+ guard let contents = try? FileManager.default.contentsOfDirectory(atPath: currentURL.path) else {
+ continue
+ }
+
+ // Check for .playground, .xcworkspace, and .xcodeproj in a single pass
+ var foundPlaygroundURL: URL?
+ var foundWorkspaceURL: URL?
+ var foundProjectURL: URL?
+ for item in contents {
+ if foundPlaygroundURL == nil, item.hasSuffix(".playground") {
+ foundPlaygroundURL = currentURL.appendingPathComponent(item)
+ }
+ if foundWorkspaceURL == nil, item.hasSuffix(".xcworkspace") {
+ foundWorkspaceURL = currentURL.appendingPathComponent(item)
+ }
+ if foundProjectURL == nil, item.hasSuffix(".xcodeproj") {
+ foundProjectURL = currentURL.appendingPathComponent(item)
+ }
+ }
+ if let playgroundURL = foundPlaygroundURL {
+ return playgroundURL
+ }
+ if let workspaceURL = foundWorkspaceURL {
+ return workspaceURL
+ }
+ if let projectURL = foundProjectURL {
+ return projectURL
+ }
+
+ // Stop at the user's home directory or root
+ if currentURL.path == "/" || currentURL.path == NSHomeDirectory() {
+ break
+ }
+ }
+
return nil
}
@@ -152,4 +218,3 @@ public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector {
return url
}
}
-
diff --git a/Tool/Tests/WorkspaceTests/WorkspaceFileTests.swift b/Tool/Tests/WorkspaceTests/WorkspaceFileTests.swift
new file mode 100644
index 00000000..87276a06
--- /dev/null
+++ b/Tool/Tests/WorkspaceTests/WorkspaceFileTests.swift
@@ -0,0 +1,460 @@
+import XCTest
+import Foundation
+@testable import Workspace
+
+class WorkspaceFileTests: XCTestCase {
+ func testMatchesPatterns() {
+ let url1 = URL(fileURLWithPath: "/path/to/file.swift")
+ let url2 = URL(fileURLWithPath: "/path/to/.git")
+ let patterns = [".git", ".svn"]
+
+ XCTAssertTrue(WorkspaceFile.matchesPatterns(url2, patterns: patterns))
+ XCTAssertFalse(WorkspaceFile.matchesPatterns(url1, patterns: patterns))
+ }
+
+ func testIsXCWorkspace() throws {
+ let tmpDir = try createTemporaryDirectory()
+ defer {
+ deleteDirectoryIfExists(at: tmpDir)
+ }
+ do {
+ let xcworkspaceURL = try createSubdirectory(in: tmpDir, withName: "myWorkspace.xcworkspace")
+ XCTAssertFalse(WorkspaceFile.isXCWorkspace(xcworkspaceURL))
+ let xcworkspaceDataURL = try createFile(in: xcworkspaceURL, withName: "contents.xcworkspacedata", contents: "")
+ XCTAssertTrue(WorkspaceFile.isXCWorkspace(xcworkspaceURL))
+ } catch {
+ throw error
+ }
+ }
+
+ func testIsXCProject() throws {
+ let tmpDir = try createTemporaryDirectory()
+ defer {
+ deleteDirectoryIfExists(at: tmpDir)
+ }
+ do {
+ let xcprojectURL = try createSubdirectory(in: tmpDir, withName: "myProject.xcodeproj")
+ XCTAssertFalse(WorkspaceFile.isXCProject(xcprojectURL))
+ let xcprojectDataURL = try createFile(in: xcprojectURL, withName: "project.pbxproj", contents: "")
+ XCTAssertTrue(WorkspaceFile.isXCProject(xcprojectURL))
+ } catch {
+ throw error
+ }
+ }
+
+ func testGetFilesInActiveProject() throws {
+ let tmpDir = try createTemporaryDirectory()
+ do {
+ let xcprojectURL = try createXCProjectFolder(in: tmpDir, withName: "myProject.xcodeproj")
+ _ = try createFile(in: tmpDir, withName: "file1.swift", contents: "")
+ _ = try createFile(in: tmpDir, withName: "file2.swift", contents: "")
+ _ = try createSubdirectory(in: tmpDir, withName: ".git")
+ let files = WorkspaceFile.getFilesInActiveWorkspace(workspaceURL: xcprojectURL, workspaceRootURL: tmpDir)
+ let fileNames = files.map { $0.url.lastPathComponent }
+ XCTAssertEqual(files.count, 2)
+ XCTAssertTrue(fileNames.contains("file1.swift"))
+ XCTAssertTrue(fileNames.contains("file2.swift"))
+ } catch {
+ deleteDirectoryIfExists(at: tmpDir)
+ throw error
+ }
+ deleteDirectoryIfExists(at: tmpDir)
+ }
+
+ func testGetFilesInActiveWorkspace() throws {
+ let tmpDir = try createTemporaryDirectory()
+ defer {
+ deleteDirectoryIfExists(at: tmpDir)
+ }
+ do {
+ let myWorkspaceRoot = try createSubdirectory(in: tmpDir, withName: "myWorkspace")
+ let xcWorkspaceURL = try createXCWorkspaceFolder(in: myWorkspaceRoot, withName: "myWorkspace.xcworkspace", fileRefs: [
+ "container:myProject.xcodeproj",
+ "group:../notExistedDir/notExistedProject.xcodeproj",
+ "group:../myDependency",])
+ let xcprojectURL = try createXCProjectFolder(in: myWorkspaceRoot, withName: "myProject.xcodeproj")
+ let myDependencyURL = try createSubdirectory(in: tmpDir, withName: "myDependency")
+
+ // Files under workspace should be included
+ _ = try createFile(in: myWorkspaceRoot, withName: "file1.swift", contents: "")
+ // unsupported patterns and file extension should be excluded
+ _ = try createFile(in: myWorkspaceRoot, withName: "unsupportedFileExtension.xyz", contents: "")
+ _ = try createSubdirectory(in: myWorkspaceRoot, withName: ".git")
+
+ // Files under project metadata folder should be excluded
+ _ = try createFile(in: xcprojectURL, withName: "fileUnderProjectMetadata.swift", contents: "")
+
+ // Files under dependency should be included
+ _ = try createFile(in: myDependencyURL, withName: "depFile1.swift", contents: "")
+ // Should be excluded
+ _ = try createSubdirectory(in: myDependencyURL, withName: ".git")
+
+ // Files under unrelated directories should be excluded
+ _ = try createFile(in: tmpDir, withName: "unrelatedFile1.swift", contents: "")
+
+ let files = WorkspaceFile.getFilesInActiveWorkspace(workspaceURL: xcWorkspaceURL, workspaceRootURL: myWorkspaceRoot)
+ let fileNames = files.map { $0.url.lastPathComponent }
+ XCTAssertEqual(files.count, 2)
+ XCTAssertTrue(fileNames.contains("file1.swift"))
+ XCTAssertTrue(fileNames.contains("depFile1.swift"))
+ } catch {
+ throw error
+ }
+ }
+
+ func testGetSubprojectURLsFromXCWorkspace() throws {
+ let tmpDir = try createTemporaryDirectory()
+ defer {
+ deleteDirectoryIfExists(at: tmpDir)
+ }
+
+ let workspaceDir = try createSubdirectory(in: tmpDir, withName: "workspace")
+
+ // Create tryapp directory and project
+ let tryappDir = try createSubdirectory(in: tmpDir, withName: "tryapp")
+ _ = try createXCProjectFolder(in: tryappDir, withName: "tryapp.xcodeproj")
+
+ // Create Copilot for Xcode project
+ _ = try createXCProjectFolder(in: workspaceDir, withName: "Copilot for Xcode.xcodeproj")
+
+ // Create Test1 directory
+ let test1Dir = try createSubdirectory(in: tmpDir, withName: "Test1")
+
+ // Create Test2 directory and project
+ let test2Dir = try createSubdirectory(in: tmpDir, withName: "Test2")
+ _ = try createXCProjectFolder(in: test2Dir, withName: "project2.xcodeproj")
+
+ // Create the workspace data file with our references
+ let xcworkspaceData = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """
+ let workspaceURL = try createXCWorkspaceFolder(in: workspaceDir, withName: "workspace.xcworkspace", xcworkspacedata: xcworkspaceData)
+
+ let subprojectURLs = WorkspaceFile.getSubprojectURLs(in: workspaceURL)
+
+ XCTAssertEqual(subprojectURLs.count, 4)
+ let resolvedPaths = subprojectURLs.map { $0.path }
+ let expectedPaths = [
+ tryappDir.path,
+ workspaceDir.path, // For Copilot for Xcode.xcodeproj
+ test1Dir.path,
+ test2Dir.path
+ ]
+ XCTAssertEqual(resolvedPaths, expectedPaths)
+ }
+
+ func testGetSubprojectURLsFromEmbeddedXCWorkspace() throws {
+ let tmpDir = try createTemporaryDirectory()
+ defer {
+ deleteDirectoryIfExists(at: tmpDir)
+ }
+
+ // Create the workspace data file with a self reference
+ let xcworkspaceData = """
+
+
+
+
+
+ """
+
+ // Create the MyApp directory structure
+ let myAppDir = try createSubdirectory(in: tmpDir, withName: "MyApp")
+ let xcodeProjectDir = try createXCProjectFolder(in: myAppDir, withName: "MyApp.xcodeproj")
+ let embeddedWorkspaceDir = try createXCWorkspaceFolder(in: xcodeProjectDir, withName: "MyApp.xcworkspace", xcworkspacedata: xcworkspaceData)
+
+ let subprojectURLs = WorkspaceFile.getSubprojectURLs(in: embeddedWorkspaceDir)
+ XCTAssertEqual(subprojectURLs.count, 1)
+ XCTAssertEqual(subprojectURLs[0].lastPathComponent, "MyApp")
+ XCTAssertEqual(subprojectURLs[0].path, myAppDir.path)
+ }
+
+ func testGetSubprojectURLsFromXCWorkspaceOrganizedByGroup() throws {
+ let tmpDir = try createTemporaryDirectory()
+ defer {
+ deleteDirectoryIfExists(at: tmpDir)
+ }
+
+ // Create directories for the projects and groups
+ let tryappDir = try createSubdirectory(in: tmpDir, withName: "tryapp")
+ _ = try createXCProjectFolder(in: tryappDir, withName: "tryapp.xcodeproj")
+
+ let webLibraryDir = try createSubdirectory(in: tmpDir, withName: "WebLibrary")
+
+ // Create the group directories
+ let group1Dir = try createSubdirectory(in: tmpDir, withName: "group1")
+ let group2Dir = try createSubdirectory(in: group1Dir, withName: "group2")
+ _ = try createSubdirectory(in: group2Dir, withName: "group3")
+ _ = try createSubdirectory(in: group1Dir, withName: "group4")
+
+ // Create the MyProjects directory
+ let myProjectsDir = try createSubdirectory(in: tmpDir, withName: "MyProjects")
+
+ // Create the copilot-xcode directory and project
+ let copilotXcodeDir = try createSubdirectory(in: myProjectsDir, withName: "copilot-xcode")
+ _ = try createXCProjectFolder(in: copilotXcodeDir, withName: "Copilot for Xcode.xcodeproj")
+
+ // Create the SwiftLanguageWeather directory and project
+ let swiftWeatherDir = try createSubdirectory(in: myProjectsDir, withName: "SwiftLanguageWeather")
+ _ = try createXCProjectFolder(in: swiftWeatherDir, withName: "SwiftWeather.xcodeproj")
+
+ // Create the workspace data file with a complex group structure
+ let xcworkspaceData = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """
+
+ // Create a test workspace structure
+ let workspaceURL = try createXCWorkspaceFolder(in: tmpDir, withName: "workspace.xcworkspace", xcworkspacedata: xcworkspaceData)
+
+ let subprojectURLs = WorkspaceFile.getSubprojectURLs(in: workspaceURL)
+ XCTAssertEqual(subprojectURLs.count, 4)
+ let expectedPaths = [
+ tryappDir.path,
+ webLibraryDir.path,
+ copilotXcodeDir.path,
+ swiftWeatherDir.path
+ ]
+ for expectedPath in expectedPaths {
+ XCTAssertTrue(subprojectURLs.contains { $0.path == expectedPath }, "Expected path not found: \(expectedPath)")
+ }
+ }
+
+ func deleteDirectoryIfExists(at url: URL) {
+ if FileManager.default.fileExists(atPath: url.path) {
+ do {
+ try FileManager.default.removeItem(at: url)
+ } catch {
+ print("Failed to delete directory at \(url.path)")
+ }
+ }
+ }
+
+ func createTemporaryDirectory() throws -> URL {
+ let temporaryDirectoryURL = FileManager.default.temporaryDirectory
+ let directoryName = UUID().uuidString
+ let directoryURL = temporaryDirectoryURL.appendingPathComponent(directoryName)
+ try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil)
+ #if DEBUG
+ print("Create temp directory \(directoryURL.path)")
+ #endif
+ return directoryURL
+ }
+
+ func createSubdirectory(in directory: URL, withName name: String) throws -> URL {
+ let subdirectoryURL = directory.appendingPathComponent(name)
+ try FileManager.default.createDirectory(at: subdirectoryURL, withIntermediateDirectories: true, attributes: nil)
+ return subdirectoryURL
+ }
+
+ func createFile(in directory: URL, withName name: String, contents: String) throws -> URL {
+ let fileURL = directory.appendingPathComponent(name)
+ let data = contents.data(using: .utf8)
+ FileManager.default.createFile(atPath: fileURL.path, contents: data, attributes: nil)
+ return fileURL
+ }
+
+ func createXCProjectFolder(in baseDirectory: URL, withName projectName: String) throws -> URL {
+ let projectURL = try createSubdirectory(in: baseDirectory, withName: projectName)
+ if projectName.hasSuffix(".xcodeproj") {
+ _ = try createFile(in: projectURL, withName: "project.pbxproj", contents: "// Project file contents")
+ }
+ return projectURL
+ }
+
+ func createXCWorkspaceFolder(in baseDirectory: URL, withName workspaceName: String, fileRefs: [String]?) throws -> URL {
+ let xcworkspaceURL = try createSubdirectory(in: baseDirectory, withName: workspaceName)
+ if let fileRefs {
+ _ = try createXCworkspacedataFile(directory: xcworkspaceURL, fileRefs: fileRefs)
+ }
+ return xcworkspaceURL
+ }
+
+ func createXCWorkspaceFolder(in baseDirectory: URL, withName workspaceName: String, xcworkspacedata: String) throws -> URL {
+ let xcworkspaceURL = try createSubdirectory(in: baseDirectory, withName: workspaceName)
+ _ = try createFile(in: xcworkspaceURL, withName: "contents.xcworkspacedata", contents: xcworkspacedata)
+ return xcworkspaceURL
+ }
+
+ func createXCworkspacedataFile(directory: URL, fileRefs: [String]) throws -> URL {
+ let contents = generateXCWorkspacedataContents(fileRefs: fileRefs)
+ return try createFile(in: directory, withName: "contents.xcworkspacedata", contents: contents)
+ }
+
+ func generateXCWorkspacedataContents(fileRefs: [String]) -> String {
+ var contents = """
+
+
+ """
+ for fileRef in fileRefs {
+ contents += """
+
+
+ """
+ }
+ contents += ""
+ return contents
+ }
+
+ func testIsValidFile() throws {
+ let tmpDir = try createTemporaryDirectory()
+ defer {
+ deleteDirectoryIfExists(at: tmpDir)
+ }
+ do {
+ // Test valid Swift file
+ let swiftFileURL = try createFile(in: tmpDir, withName: "ValidFile.swift", contents: "// Swift code")
+ XCTAssertTrue(try WorkspaceFile.isValidFile(swiftFileURL))
+
+ // Test valid files with different supported extensions
+ let jsFileURL = try createFile(in: tmpDir, withName: "script.js", contents: "// JavaScript")
+ XCTAssertTrue(try WorkspaceFile.isValidFile(jsFileURL))
+
+ let mdFileURL = try createFile(in: tmpDir, withName: "README.md", contents: "# Markdown")
+ XCTAssertTrue(try WorkspaceFile.isValidFile(mdFileURL))
+
+ let jsonFileURL = try createFile(in: tmpDir, withName: "config.json", contents: "{}")
+ XCTAssertTrue(try WorkspaceFile.isValidFile(jsonFileURL))
+
+ // Test case insensitive extension matching
+ let swiftUpperURL = try createFile(in: tmpDir, withName: "File.SWIFT", contents: "// Swift")
+ XCTAssertTrue(try WorkspaceFile.isValidFile(swiftUpperURL))
+
+ // Test unsupported file extension
+ let unsupportedFileURL = try createFile(in: tmpDir, withName: "file.xyz", contents: "unsupported")
+ XCTAssertFalse(try WorkspaceFile.isValidFile(unsupportedFileURL))
+
+ // Test files matching skip patterns
+ let gitFileURL = try createFile(in: tmpDir, withName: ".git", contents: "")
+ XCTAssertFalse(try WorkspaceFile.isValidFile(gitFileURL))
+
+ let dsStoreURL = try createFile(in: tmpDir, withName: ".DS_Store", contents: "")
+ XCTAssertFalse(try WorkspaceFile.isValidFile(dsStoreURL))
+
+ let nodeModulesURL = try createFile(in: tmpDir, withName: "node_modules", contents: "")
+ XCTAssertFalse(try WorkspaceFile.isValidFile(nodeModulesURL))
+
+ // Test directory (should return false)
+ let subdirURL = try createSubdirectory(in: tmpDir, withName: "subdir")
+ XCTAssertFalse(try WorkspaceFile.isValidFile(subdirURL))
+
+ // Test Xcode workspace (should return false)
+ let xcworkspaceURL = try createSubdirectory(in: tmpDir, withName: "test.xcworkspace")
+ _ = try createFile(in: xcworkspaceURL, withName: "contents.xcworkspacedata", contents: "")
+ XCTAssertFalse(try WorkspaceFile.isValidFile(xcworkspaceURL))
+
+ // Test Xcode project (should return false)
+ let xcprojectURL = try createSubdirectory(in: tmpDir, withName: "test.xcodeproj")
+ _ = try createFile(in: xcprojectURL, withName: "project.pbxproj", contents: "")
+ XCTAssertFalse(try WorkspaceFile.isValidFile(xcprojectURL))
+
+ } catch {
+ throw error
+ }
+ }
+
+ func testIsValidFileWithCustomExclusionFilter() throws {
+ let tmpDir = try createTemporaryDirectory()
+ defer {
+ deleteDirectoryIfExists(at: tmpDir)
+ }
+ do {
+ let swiftFileURL = try createFile(in: tmpDir, withName: "TestFile.swift", contents: "// Swift code")
+ let jsFileURL = try createFile(in: tmpDir, withName: "script.js", contents: "// JavaScript")
+
+ // Test without custom exclusion filter
+ XCTAssertTrue(try WorkspaceFile.isValidFile(swiftFileURL))
+ XCTAssertTrue(try WorkspaceFile.isValidFile(jsFileURL))
+
+ // Test with custom exclusion filter that excludes Swift files
+ let excludeSwiftFilter: (URL) -> Bool = { url in
+ return url.pathExtension.lowercased() == "swift"
+ }
+
+ XCTAssertFalse(try WorkspaceFile.isValidFile(swiftFileURL, shouldExcludeFile: excludeSwiftFilter))
+ XCTAssertTrue(try WorkspaceFile.isValidFile(jsFileURL, shouldExcludeFile: excludeSwiftFilter))
+
+ // Test with custom exclusion filter that excludes files with "Test" in name
+ let excludeTestFilter: (URL) -> Bool = { url in
+ return url.lastPathComponent.contains("Test")
+ }
+
+ XCTAssertFalse(try WorkspaceFile.isValidFile(swiftFileURL, shouldExcludeFile: excludeTestFilter))
+ XCTAssertTrue(try WorkspaceFile.isValidFile(jsFileURL, shouldExcludeFile: excludeTestFilter))
+
+ } catch {
+ throw error
+ }
+ }
+
+ func testIsValidFileWithAllSupportedExtensions() throws {
+ let tmpDir = try createTemporaryDirectory()
+ defer {
+ deleteDirectoryIfExists(at: tmpDir)
+ }
+ do {
+ let supportedExtensions = supportedFileExtensions
+
+ for (index, ext) in supportedExtensions.enumerated() {
+ let fileName = "testfile\(index).\(ext)"
+ let fileURL = try createFile(in: tmpDir, withName: fileName, contents: "test content")
+ XCTAssertTrue(try WorkspaceFile.isValidFile(fileURL), "File with extension .\(ext) should be valid")
+ }
+
+ } catch {
+ throw error
+ }
+ }
+}
diff --git a/Tool/Tests/WorkspaceTests/WorkspaceTests.swift b/Tool/Tests/WorkspaceTests/WorkspaceTests.swift
index 87276a06..d6d2e5ec 100644
--- a/Tool/Tests/WorkspaceTests/WorkspaceTests.swift
+++ b/Tool/Tests/WorkspaceTests/WorkspaceTests.swift
@@ -1,460 +1,230 @@
import XCTest
import Foundation
+import LanguageServerProtocol
@testable import Workspace
-class WorkspaceFileTests: XCTestCase {
- func testMatchesPatterns() {
- let url1 = URL(fileURLWithPath: "/path/to/file.swift")
- let url2 = URL(fileURLWithPath: "/path/to/.git")
- let patterns = [".git", ".svn"]
-
- XCTAssertTrue(WorkspaceFile.matchesPatterns(url2, patterns: patterns))
- XCTAssertFalse(WorkspaceFile.matchesPatterns(url1, patterns: patterns))
- }
-
- func testIsXCWorkspace() throws {
- let tmpDir = try createTemporaryDirectory()
- defer {
- deleteDirectoryIfExists(at: tmpDir)
- }
- do {
- let xcworkspaceURL = try createSubdirectory(in: tmpDir, withName: "myWorkspace.xcworkspace")
- XCTAssertFalse(WorkspaceFile.isXCWorkspace(xcworkspaceURL))
- let xcworkspaceDataURL = try createFile(in: xcworkspaceURL, withName: "contents.xcworkspacedata", contents: "")
- XCTAssertTrue(WorkspaceFile.isXCWorkspace(xcworkspaceURL))
- } catch {
- throw error
- }
- }
-
- func testIsXCProject() throws {
- let tmpDir = try createTemporaryDirectory()
- defer {
- deleteDirectoryIfExists(at: tmpDir)
- }
- do {
- let xcprojectURL = try createSubdirectory(in: tmpDir, withName: "myProject.xcodeproj")
- XCTAssertFalse(WorkspaceFile.isXCProject(xcprojectURL))
- let xcprojectDataURL = try createFile(in: xcprojectURL, withName: "project.pbxproj", contents: "")
- XCTAssertTrue(WorkspaceFile.isXCProject(xcprojectURL))
- } catch {
- throw error
- }
- }
-
- func testGetFilesInActiveProject() throws {
- let tmpDir = try createTemporaryDirectory()
- do {
- let xcprojectURL = try createXCProjectFolder(in: tmpDir, withName: "myProject.xcodeproj")
- _ = try createFile(in: tmpDir, withName: "file1.swift", contents: "")
- _ = try createFile(in: tmpDir, withName: "file2.swift", contents: "")
- _ = try createSubdirectory(in: tmpDir, withName: ".git")
- let files = WorkspaceFile.getFilesInActiveWorkspace(workspaceURL: xcprojectURL, workspaceRootURL: tmpDir)
- let fileNames = files.map { $0.url.lastPathComponent }
- XCTAssertEqual(files.count, 2)
- XCTAssertTrue(fileNames.contains("file1.swift"))
- XCTAssertTrue(fileNames.contains("file2.swift"))
- } catch {
- deleteDirectoryIfExists(at: tmpDir)
- throw error
- }
- deleteDirectoryIfExists(at: tmpDir)
- }
-
- func testGetFilesInActiveWorkspace() throws {
- let tmpDir = try createTemporaryDirectory()
- defer {
- deleteDirectoryIfExists(at: tmpDir)
- }
- do {
- let myWorkspaceRoot = try createSubdirectory(in: tmpDir, withName: "myWorkspace")
- let xcWorkspaceURL = try createXCWorkspaceFolder(in: myWorkspaceRoot, withName: "myWorkspace.xcworkspace", fileRefs: [
- "container:myProject.xcodeproj",
- "group:../notExistedDir/notExistedProject.xcodeproj",
- "group:../myDependency",])
- let xcprojectURL = try createXCProjectFolder(in: myWorkspaceRoot, withName: "myProject.xcodeproj")
- let myDependencyURL = try createSubdirectory(in: tmpDir, withName: "myDependency")
-
- // Files under workspace should be included
- _ = try createFile(in: myWorkspaceRoot, withName: "file1.swift", contents: "")
- // unsupported patterns and file extension should be excluded
- _ = try createFile(in: myWorkspaceRoot, withName: "unsupportedFileExtension.xyz", contents: "")
- _ = try createSubdirectory(in: myWorkspaceRoot, withName: ".git")
-
- // Files under project metadata folder should be excluded
- _ = try createFile(in: xcprojectURL, withName: "fileUnderProjectMetadata.swift", contents: "")
-
- // Files under dependency should be included
- _ = try createFile(in: myDependencyURL, withName: "depFile1.swift", contents: "")
- // Should be excluded
- _ = try createSubdirectory(in: myDependencyURL, withName: ".git")
-
- // Files under unrelated directories should be excluded
- _ = try createFile(in: tmpDir, withName: "unrelatedFile1.swift", contents: "")
-
- let files = WorkspaceFile.getFilesInActiveWorkspace(workspaceURL: xcWorkspaceURL, workspaceRootURL: myWorkspaceRoot)
- let fileNames = files.map { $0.url.lastPathComponent }
- XCTAssertEqual(files.count, 2)
- XCTAssertTrue(fileNames.contains("file1.swift"))
- XCTAssertTrue(fileNames.contains("depFile1.swift"))
- } catch {
- throw error
- }
+class WorkspaceTests: XCTestCase {
+ func testCalculateIncrementalChanges_IdenticalContent() {
+ let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test"))
+ let oldContent = "Hello World"
+ let newContent = "Hello World"
+
+ let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent)
+
+ XCTAssertNil(changes, "Identical content should return nil")
}
-
- func testGetSubprojectURLsFromXCWorkspace() throws {
- let tmpDir = try createTemporaryDirectory()
- defer {
- deleteDirectoryIfExists(at: tmpDir)
- }
-
- let workspaceDir = try createSubdirectory(in: tmpDir, withName: "workspace")
-
- // Create tryapp directory and project
- let tryappDir = try createSubdirectory(in: tmpDir, withName: "tryapp")
- _ = try createXCProjectFolder(in: tryappDir, withName: "tryapp.xcodeproj")
-
- // Create Copilot for Xcode project
- _ = try createXCProjectFolder(in: workspaceDir, withName: "Copilot for Xcode.xcodeproj")
-
- // Create Test1 directory
- let test1Dir = try createSubdirectory(in: tmpDir, withName: "Test1")
-
- // Create Test2 directory and project
- let test2Dir = try createSubdirectory(in: tmpDir, withName: "Test2")
- _ = try createXCProjectFolder(in: test2Dir, withName: "project2.xcodeproj")
-
- // Create the workspace data file with our references
- let xcworkspaceData = """
-
-
-
-
-
-
-
-
-
-
-
-
-
- """
- let workspaceURL = try createXCWorkspaceFolder(in: workspaceDir, withName: "workspace.xcworkspace", xcworkspacedata: xcworkspaceData)
-
- let subprojectURLs = WorkspaceFile.getSubprojectURLs(in: workspaceURL)
-
- XCTAssertEqual(subprojectURLs.count, 4)
- let resolvedPaths = subprojectURLs.map { $0.path }
- let expectedPaths = [
- tryappDir.path,
- workspaceDir.path, // For Copilot for Xcode.xcodeproj
- test1Dir.path,
- test2Dir.path
- ]
- XCTAssertEqual(resolvedPaths, expectedPaths)
+
+ func testCalculateIncrementalChanges_EmptyOldContent() {
+ let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test"))
+ let oldContent = ""
+ let newContent = "New content"
+
+ let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent)
+
+ XCTAssertNotNil(changes)
+ XCTAssertEqual(changes?.count, 1)
+ XCTAssertEqual(changes?[0].range, LSPRange(start: Position(line: 0, character: 0), end: Position(line: 0, character: 0)))
+ XCTAssertEqual(changes?[0].text, "New content")
}
-
- func testGetSubprojectURLsFromEmbeddedXCWorkspace() throws {
- let tmpDir = try createTemporaryDirectory()
- defer {
- deleteDirectoryIfExists(at: tmpDir)
- }
-
- // Create the workspace data file with a self reference
- let xcworkspaceData = """
-
-
-
-
-
- """
-
- // Create the MyApp directory structure
- let myAppDir = try createSubdirectory(in: tmpDir, withName: "MyApp")
- let xcodeProjectDir = try createXCProjectFolder(in: myAppDir, withName: "MyApp.xcodeproj")
- let embeddedWorkspaceDir = try createXCWorkspaceFolder(in: xcodeProjectDir, withName: "MyApp.xcworkspace", xcworkspacedata: xcworkspaceData)
-
- let subprojectURLs = WorkspaceFile.getSubprojectURLs(in: embeddedWorkspaceDir)
- XCTAssertEqual(subprojectURLs.count, 1)
- XCTAssertEqual(subprojectURLs[0].lastPathComponent, "MyApp")
- XCTAssertEqual(subprojectURLs[0].path, myAppDir.path)
+
+ func testCalculateIncrementalChanges_EmptyNewContent() {
+ let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test"))
+ let oldContent = "Old content"
+ let newContent = ""
+
+ let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent)
+
+ XCTAssertNotNil(changes)
+ XCTAssertEqual(changes?.count, 1)
+ XCTAssertEqual(changes?[0].text, "")
+ XCTAssertEqual(changes?[0].range?.start.line, 0)
+ XCTAssertEqual(changes?[0].range?.start.character, 0)
+ XCTAssertEqual(changes?[0].rangeLength, oldContent.utf16.count)
}
-
- func testGetSubprojectURLsFromXCWorkspaceOrganizedByGroup() throws {
- let tmpDir = try createTemporaryDirectory()
- defer {
- deleteDirectoryIfExists(at: tmpDir)
- }
-
- // Create directories for the projects and groups
- let tryappDir = try createSubdirectory(in: tmpDir, withName: "tryapp")
- _ = try createXCProjectFolder(in: tryappDir, withName: "tryapp.xcodeproj")
-
- let webLibraryDir = try createSubdirectory(in: tmpDir, withName: "WebLibrary")
-
- // Create the group directories
- let group1Dir = try createSubdirectory(in: tmpDir, withName: "group1")
- let group2Dir = try createSubdirectory(in: group1Dir, withName: "group2")
- _ = try createSubdirectory(in: group2Dir, withName: "group3")
- _ = try createSubdirectory(in: group1Dir, withName: "group4")
-
- // Create the MyProjects directory
- let myProjectsDir = try createSubdirectory(in: tmpDir, withName: "MyProjects")
-
- // Create the copilot-xcode directory and project
- let copilotXcodeDir = try createSubdirectory(in: myProjectsDir, withName: "copilot-xcode")
- _ = try createXCProjectFolder(in: copilotXcodeDir, withName: "Copilot for Xcode.xcodeproj")
-
- // Create the SwiftLanguageWeather directory and project
- let swiftWeatherDir = try createSubdirectory(in: myProjectsDir, withName: "SwiftLanguageWeather")
- _ = try createXCProjectFolder(in: swiftWeatherDir, withName: "SwiftWeather.xcodeproj")
-
- // Create the workspace data file with a complex group structure
- let xcworkspaceData = """
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- """
-
- // Create a test workspace structure
- let workspaceURL = try createXCWorkspaceFolder(in: tmpDir, withName: "workspace.xcworkspace", xcworkspacedata: xcworkspaceData)
-
- let subprojectURLs = WorkspaceFile.getSubprojectURLs(in: workspaceURL)
- XCTAssertEqual(subprojectURLs.count, 4)
- let expectedPaths = [
- tryappDir.path,
- webLibraryDir.path,
- copilotXcodeDir.path,
- swiftWeatherDir.path
- ]
- for expectedPath in expectedPaths {
- XCTAssertTrue(subprojectURLs.contains { $0.path == expectedPath }, "Expected path not found: \(expectedPath)")
- }
+
+ func testCalculateIncrementalChanges_InsertAtBeginning() {
+ let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test"))
+ let oldContent = "World"
+ let newContent = "Hello World"
+
+ let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent)
+
+ XCTAssertNotNil(changes)
+ XCTAssertEqual(changes?.count, 1)
+ XCTAssertEqual(changes?[0].range?.start.line, 0)
+ XCTAssertEqual(changes?[0].range?.start.character, 0)
+ XCTAssertEqual(changes?[0].text, "Hello ")
}
-
- func deleteDirectoryIfExists(at url: URL) {
- if FileManager.default.fileExists(atPath: url.path) {
- do {
- try FileManager.default.removeItem(at: url)
- } catch {
- print("Failed to delete directory at \(url.path)")
- }
- }
+
+ func testCalculateIncrementalChanges_InsertAtEnd() {
+ let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test"))
+ let oldContent = "Hello"
+ let newContent = "Hello World"
+
+ let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent)
+
+ XCTAssertNotNil(changes)
+ XCTAssertEqual(changes?.count, 1)
+ XCTAssertEqual(changes?[0].range?.start.line, 0)
+ XCTAssertEqual(changes?[0].range?.start.character, 5)
+ XCTAssertEqual(changes?[0].text, " World")
}
-
- func createTemporaryDirectory() throws -> URL {
- let temporaryDirectoryURL = FileManager.default.temporaryDirectory
- let directoryName = UUID().uuidString
- let directoryURL = temporaryDirectoryURL.appendingPathComponent(directoryName)
- try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil)
- #if DEBUG
- print("Create temp directory \(directoryURL.path)")
- #endif
- return directoryURL
+
+ func testCalculateIncrementalChanges_InsertInMiddle() {
+ let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test"))
+ let oldContent = "Hello World"
+ let newContent = "Hello Beautiful World"
+
+ let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent)
+
+ XCTAssertNotNil(changes)
+ XCTAssertEqual(changes?.count, 1)
+ XCTAssertEqual(changes?[0].range?.start.line, 0)
+ XCTAssertEqual(changes?[0].range?.start.character, 6)
+ XCTAssertEqual(changes?[0].text, "Beautiful ")
}
-
- func createSubdirectory(in directory: URL, withName name: String) throws -> URL {
- let subdirectoryURL = directory.appendingPathComponent(name)
- try FileManager.default.createDirectory(at: subdirectoryURL, withIntermediateDirectories: true, attributes: nil)
- return subdirectoryURL
+
+ func testCalculateIncrementalChanges_DeleteFromBeginning() {
+ let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test"))
+ let oldContent = "Hello World"
+ let newContent = "World"
+
+ let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent)
+
+ XCTAssertNotNil(changes)
+ XCTAssertEqual(changes?.count, 1)
+ XCTAssertEqual(changes?[0].range?.start.line, 0)
+ XCTAssertEqual(changes?[0].range?.start.character, 0)
+ XCTAssertEqual(changes?[0].range?.end.character, 6)
+ XCTAssertEqual(changes?[0].text, "")
}
-
- func createFile(in directory: URL, withName name: String, contents: String) throws -> URL {
- let fileURL = directory.appendingPathComponent(name)
- let data = contents.data(using: .utf8)
- FileManager.default.createFile(atPath: fileURL.path, contents: data, attributes: nil)
- return fileURL
+
+ func testCalculateIncrementalChanges_DeleteFromEnd() {
+ let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test"))
+ let oldContent = "Hello World"
+ let newContent = "Hello"
+
+ let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent)
+
+ XCTAssertNotNil(changes)
+ XCTAssertEqual(changes?.count, 1)
+ XCTAssertEqual(changes?[0].range?.start.line, 0)
+ XCTAssertEqual(changes?[0].range?.start.character, 5)
+ XCTAssertEqual(changes?[0].text, "")
}
+
+ func testCalculateIncrementalChanges_ReplaceMiddle() {
+ let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test"))
+ let oldContent = "Hello World"
+ let newContent = "Hello Swift"
+
+ let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent)
- func createXCProjectFolder(in baseDirectory: URL, withName projectName: String) throws -> URL {
- let projectURL = try createSubdirectory(in: baseDirectory, withName: projectName)
- if projectName.hasSuffix(".xcodeproj") {
- _ = try createFile(in: projectURL, withName: "project.pbxproj", contents: "// Project file contents")
- }
- return projectURL
+ XCTAssertNotNil(changes)
+ XCTAssertEqual(changes?.count, 1)
+ XCTAssertEqual(changes?[0].range?.start.line, 0)
+ XCTAssertEqual(changes?[0].range?.start.character, 6)
+ XCTAssertEqual(changes?[0].text, "Swift")
}
-
- func createXCWorkspaceFolder(in baseDirectory: URL, withName workspaceName: String, fileRefs: [String]?) throws -> URL {
- let xcworkspaceURL = try createSubdirectory(in: baseDirectory, withName: workspaceName)
- if let fileRefs {
- _ = try createXCworkspacedataFile(directory: xcworkspaceURL, fileRefs: fileRefs)
- }
- return xcworkspaceURL
+
+ func testCalculateIncrementalChanges_MultilineInsert() {
+ let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test"))
+ let oldContent = "Line 1\nLine 3"
+ let newContent = "Line 1\nLine 2\nLine 3"
+
+ let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent)
+
+ XCTAssertNotNil(changes)
+ XCTAssertEqual(changes?.count, 1)
+ XCTAssertEqual(changes?[0].range?.start.line, 1)
+ XCTAssertEqual(changes?[0].range?.start.character, 5)
+ XCTAssertEqual(changes?[0].text, "2\nLine ")
}
-
- func createXCWorkspaceFolder(in baseDirectory: URL, withName workspaceName: String, xcworkspacedata: String) throws -> URL {
- let xcworkspaceURL = try createSubdirectory(in: baseDirectory, withName: workspaceName)
- _ = try createFile(in: xcworkspaceURL, withName: "contents.xcworkspacedata", contents: xcworkspacedata)
- return xcworkspaceURL
+
+ func testCalculateIncrementalChanges_MultilineDelete() {
+ let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test"))
+ let oldContent = "Line 1\nLine 2\nLine 3"
+ let newContent = "Line 1\nLine 3"
+
+ let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent)
+
+ XCTAssertNotNil(changes)
+ XCTAssertEqual(changes?.count, 1)
+ XCTAssertEqual(changes?[0].range?.start.line, 1)
+ XCTAssertEqual(changes?[0].range?.start.character, 5)
+ XCTAssertEqual(changes?[0].range?.end.line, 2)
+ XCTAssertEqual(changes?[0].text, "")
}
-
- func createXCworkspacedataFile(directory: URL, fileRefs: [String]) throws -> URL {
- let contents = generateXCWorkspacedataContents(fileRefs: fileRefs)
- return try createFile(in: directory, withName: "contents.xcworkspacedata", contents: contents)
+
+ func testCalculateIncrementalChanges_MultilineReplace() {
+ let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test"))
+ let oldContent = "Line 1\nOld Line\nLine 3"
+ let newContent = "Line 1\nNew Line\nLine 3"
+
+ let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent)
+
+ XCTAssertNotNil(changes)
+ XCTAssertEqual(changes?.count, 1)
+ XCTAssertEqual(changes?[0].range?.start.line, 1)
+ XCTAssertEqual(changes?[0].text, "New")
}
-
- func generateXCWorkspacedataContents(fileRefs: [String]) -> String {
- var contents = """
-
-
- """
- for fileRef in fileRefs {
- contents += """
-
-
- """
- }
- contents += ""
- return contents
+
+ func testCalculateIncrementalChanges_UTF16Characters() {
+ let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test"))
+ let oldContent = "Hello δΈη"
+ let newContent = "Hello π δΈη"
+
+ let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent)
+
+ XCTAssertNotNil(changes)
+ XCTAssertEqual(changes?.count, 1)
+ XCTAssertEqual(changes?[0].range?.start.line, 0)
+ XCTAssertEqual(changes?[0].range?.start.character, 6)
+ XCTAssertEqual(changes?[0].text, "π ")
}
- func testIsValidFile() throws {
- let tmpDir = try createTemporaryDirectory()
- defer {
- deleteDirectoryIfExists(at: tmpDir)
- }
- do {
- // Test valid Swift file
- let swiftFileURL = try createFile(in: tmpDir, withName: "ValidFile.swift", contents: "// Swift code")
- XCTAssertTrue(try WorkspaceFile.isValidFile(swiftFileURL))
-
- // Test valid files with different supported extensions
- let jsFileURL = try createFile(in: tmpDir, withName: "script.js", contents: "// JavaScript")
- XCTAssertTrue(try WorkspaceFile.isValidFile(jsFileURL))
-
- let mdFileURL = try createFile(in: tmpDir, withName: "README.md", contents: "# Markdown")
- XCTAssertTrue(try WorkspaceFile.isValidFile(mdFileURL))
-
- let jsonFileURL = try createFile(in: tmpDir, withName: "config.json", contents: "{}")
- XCTAssertTrue(try WorkspaceFile.isValidFile(jsonFileURL))
-
- // Test case insensitive extension matching
- let swiftUpperURL = try createFile(in: tmpDir, withName: "File.SWIFT", contents: "// Swift")
- XCTAssertTrue(try WorkspaceFile.isValidFile(swiftUpperURL))
-
- // Test unsupported file extension
- let unsupportedFileURL = try createFile(in: tmpDir, withName: "file.xyz", contents: "unsupported")
- XCTAssertFalse(try WorkspaceFile.isValidFile(unsupportedFileURL))
-
- // Test files matching skip patterns
- let gitFileURL = try createFile(in: tmpDir, withName: ".git", contents: "")
- XCTAssertFalse(try WorkspaceFile.isValidFile(gitFileURL))
-
- let dsStoreURL = try createFile(in: tmpDir, withName: ".DS_Store", contents: "")
- XCTAssertFalse(try WorkspaceFile.isValidFile(dsStoreURL))
-
- let nodeModulesURL = try createFile(in: tmpDir, withName: "node_modules", contents: "")
- XCTAssertFalse(try WorkspaceFile.isValidFile(nodeModulesURL))
-
- // Test directory (should return false)
- let subdirURL = try createSubdirectory(in: tmpDir, withName: "subdir")
- XCTAssertFalse(try WorkspaceFile.isValidFile(subdirURL))
-
- // Test Xcode workspace (should return false)
- let xcworkspaceURL = try createSubdirectory(in: tmpDir, withName: "test.xcworkspace")
- _ = try createFile(in: xcworkspaceURL, withName: "contents.xcworkspacedata", contents: "")
- XCTAssertFalse(try WorkspaceFile.isValidFile(xcworkspaceURL))
-
- // Test Xcode project (should return false)
- let xcprojectURL = try createSubdirectory(in: tmpDir, withName: "test.xcodeproj")
- _ = try createFile(in: xcprojectURL, withName: "project.pbxproj", contents: "")
- XCTAssertFalse(try WorkspaceFile.isValidFile(xcprojectURL))
-
- } catch {
- throw error
- }
+ func testCalculateIncrementalChanges_VeryLargeContent() {
+ let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test"))
+ let oldContent = String(repeating: "a", count: 220_000)
+ let newContent = String(repeating: "b", count: 220_000)
+
+ let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent)
+
+ // Should fallback to nil for very large contents (> 200_000 characters)
+ XCTAssertNil(changes, "Very large content should return nil for fallback")
}
- func testIsValidFileWithCustomExclusionFilter() throws {
- let tmpDir = try createTemporaryDirectory()
- defer {
- deleteDirectoryIfExists(at: tmpDir)
- }
- do {
- let swiftFileURL = try createFile(in: tmpDir, withName: "TestFile.swift", contents: "// Swift code")
- let jsFileURL = try createFile(in: tmpDir, withName: "script.js", contents: "// JavaScript")
-
- // Test without custom exclusion filter
- XCTAssertTrue(try WorkspaceFile.isValidFile(swiftFileURL))
- XCTAssertTrue(try WorkspaceFile.isValidFile(jsFileURL))
-
- // Test with custom exclusion filter that excludes Swift files
- let excludeSwiftFilter: (URL) -> Bool = { url in
- return url.pathExtension.lowercased() == "swift"
- }
-
- XCTAssertFalse(try WorkspaceFile.isValidFile(swiftFileURL, shouldExcludeFile: excludeSwiftFilter))
- XCTAssertTrue(try WorkspaceFile.isValidFile(jsFileURL, shouldExcludeFile: excludeSwiftFilter))
-
- // Test with custom exclusion filter that excludes files with "Test" in name
- let excludeTestFilter: (URL) -> Bool = { url in
- return url.lastPathComponent.contains("Test")
- }
-
- XCTAssertFalse(try WorkspaceFile.isValidFile(swiftFileURL, shouldExcludeFile: excludeTestFilter))
- XCTAssertTrue(try WorkspaceFile.isValidFile(jsFileURL, shouldExcludeFile: excludeTestFilter))
-
- } catch {
- throw error
- }
+ func testCalculateIncrementalChanges_ComplexEdit() {
+ let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test"))
+ let oldContent = """
+ func hello() {
+ print("Hello")
+ }
+ """
+ let newContent = """
+ func hello(name: String) {
+ print("Hello, \\(name)!")
+ }
+ """
+
+ let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent)
+
+ XCTAssertNotNil(changes)
+ XCTAssertEqual(changes?.count, 1)
+ // Verify that a change was detected
+ XCTAssertFalse(changes?[0].text.isEmpty ?? true)
}
- func testIsValidFileWithAllSupportedExtensions() throws {
- let tmpDir = try createTemporaryDirectory()
- defer {
- deleteDirectoryIfExists(at: tmpDir)
- }
- do {
- let supportedExtensions = supportedFileExtensions
-
- for (index, ext) in supportedExtensions.enumerated() {
- let fileName = "testfile\(index).\(ext)"
- let fileURL = try createFile(in: tmpDir, withName: fileName, contents: "test content")
- XCTAssertTrue(try WorkspaceFile.isValidFile(fileURL), "File with extension .\(ext) should be valid")
- }
-
- } catch {
- throw error
- }
+ func testCalculateIncrementalChanges_NewlineVariations() {
+ let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test"))
+ let oldContent = "Line 1\nLine 2"
+ let newContent = "Line 1\nLine 2\n"
+
+ let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent)
+
+ XCTAssertNotNil(changes)
+ XCTAssertEqual(changes?.count, 1)
+ XCTAssertEqual(changes?[0].range?.start.line, 1)
+ XCTAssertEqual(changes?[0].text, "\n")
}
}
diff --git a/marketplace.json b/marketplace.json
new file mode 100644
index 00000000..7dd23f7e
--- /dev/null
+++ b/marketplace.json
@@ -0,0 +1,17 @@
+{
+ "name": "GitHub Copilot",
+ "description": "GitHub's AI pair programmer",
+ "publisher": {
+ "name": "GitHub",
+ "url": "https://github.com/github",
+ "verified": true
+ },
+ "repository": "https://github.com/github/CopilotForXcode",
+ "license": "proprietary",
+ "plugins": [
+ {
+ "path": ".agent-plugin",
+ "type": "acp-agent"
+ }
+ ]
+}