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/actions/set-xcode-version/action.yml b/.github/actions/set-xcode-version/action.yml
index 4d8dc817..b8831351 100644
--- a/.github/actions/set-xcode-version/action.yml
+++ b/.github/actions/set-xcode-version/action.yml
@@ -6,7 +6,7 @@ inputs:
Xcode version to use, in semver(ish)-style matching the format on the Actions runner image.
See available versions at https://github.com/actions/runner-images/blame/main/images/macos/macos-14-Readme.md#xcode
required: false
- default: '16.2'
+ default: '26.0'
outputs:
xcode-path:
description: "Path to current Xcode version"
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/.gitignore b/.gitignore
index 9aa8393c..2a7f67ef 100644
--- a/.gitignore
+++ b/.gitignore
@@ -123,3 +123,4 @@ Server/dist
/releases/
/release/
/appcast.xml
+
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9554f9dd..42463f66 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,108 @@ 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.
+- Added support for restoring to a saved checkpoint snapshot.
+- Added support for tool selection in agent mode.
+- Added the ability to adjust the chat panel font size.
+- Added the ability to edit a previous chat message and resend it.
+- Introduced a new setting to disable the Copilot โFix Errorโ button.
+- Added support for custom instructions in the Code Review feature.
+
+### Changed
+- Switched authentication to a new OAuth app "GitHub Copilot IDE Plugin".
+- Updated the chat layout to a messenger-style conversation view (user messages on the right, responses on the left).
+- Now shows a clearer, more user-friendly message when Copilot finishes responding.
+- Added support for skipping a tool call without ending the conversation.
+
+### Fixed
+- Fixed a command injection vulnerability when opening referenced chat files.
+- Resolved display issues in the chat view on macOS 26.
+
## 0.43.0 - September 4, 2025
### Fixed
- Cannot type non-Latin characters in the chat input field.
diff --git a/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 844f7d7c..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 */; };
@@ -188,11 +190,13 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
- 3ABBEA282C8B9FE100C61D61 /* copilot-language-server */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.executable"; name = "copilot-language-server"; path = "Server/node_modules/@github/copilot-language-server/native/darwin-x64/copilot-language-server"; sourceTree = SOURCE_ROOT; };
- 3ABBEA2A2C8BA00300C61D61 /* copilot-language-server-arm64 */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.executable"; name = "copilot-language-server-arm64"; path = "Server/node_modules/@github/copilot-language-server/native/darwin-arm64/copilot-language-server-arm64"; sourceTree = SOURCE_ROOT; };
+ 3ABBEA282C8B9FE100C61D61 /* copilot-language-server */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.executable"; name = "copilot-language-server"; path = "Server/node_modules/@github/copilot-language-server-darwin-x64/copilot-language-server"; sourceTree = SOURCE_ROOT; };
+ 3ABBEA2A2C8BA00300C61D61 /* copilot-language-server-arm64 */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.executable"; name = "copilot-language-server-arm64"; path = "Server/node_modules/@github/copilot-language-server-darwin-arm64/copilot-language-server-arm64"; sourceTree = SOURCE_ROOT; };
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 */,
@@ -719,7 +725,7 @@
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
- shellScript = "export PATH=/usr/local/bin:/opt/homebrew/bin:$PATH\n\nnpm -C Server install\ncp Server/node_modules/@github/copilot-language-server/native/darwin-arm64/copilot-language-server Server/node_modules/@github/copilot-language-server/native/darwin-arm64/copilot-language-server-arm64\n\necho \"Build and copy webview js/html files as the bundle resources\"\nnpm -C Server run build\nmkdir -p \"${BUILT_PRODUCTS_DIR}/${CONTENTS_FOLDER_PATH}/Resources/webViewDist\"\ncp -R Server/dist/* \"${BUILT_PRODUCTS_DIR}/${CONTENTS_FOLDER_PATH}/Resources/webViewDist/\"\n";
+ shellScript = "export PATH=/usr/local/bin:/opt/homebrew/bin:$PATH\n\nnpm -C Server install --force\ncp Server/node_modules/@github/copilot-language-server-darwin-arm64/copilot-language-server Server/node_modules/@github/copilot-language-server-darwin-arm64/copilot-language-server-arm64\n\necho \"Build and copy webview js/html files as the bundle resources\"\nnpm -C Server run build\nmkdir -p \"${BUILT_PRODUCTS_DIR}/${CONTENTS_FOLDER_PATH}/Resources/webViewDist\"\ncp -R Server/dist/* \"${BUILT_PRODUCTS_DIR}/${CONTENTS_FOLDER_PATH}/Resources/webViewDist/\"\n";
};
/* End PBXShellScriptBuildPhase section */
@@ -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 70cb846e..d45adb9f 100644
--- a/Copilot for Xcode/App.swift
+++ b/Copilot for Xcode/App.swift
@@ -20,24 +20,21 @@ class AppDelegate: NSObject, NSApplicationDelegate {
enum LaunchMode {
case chat
case settings
- case mcp
+ 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
@@ -49,8 +46,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
let launchArgs = CommandLine.arguments
if launchArgs.contains("--settings") {
return .settings
- } else if launchArgs.contains("--mcp") {
- return .mcp
+ } else if launchArgs.contains("--tools") {
+ return .tools
+ } else if launchArgs.contains("--tools-auto-approve") {
+ return .toolsAutoApprove
} else if launchArgs.contains("--byok") {
return .byok
} else {
@@ -62,8 +61,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
switch mode {
case .settings:
openSettings()
- case .mcp:
- openMCPSettings()
+ case .tools:
+ openToolsSettings()
+ case .toolsAutoApprove:
+ openToolsSettingsAutoApprove()
case .byok:
openBYOKSettings()
case .chat:
@@ -86,10 +87,18 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
}
- private func openMCPSettings() {
+ private func openToolsSettings() {
+ DispatchQueue.main.async {
+ activateAndOpenSettings()
+ hostAppStore.send(.setActiveTab(.tools))
+ }
+ }
+
+ private func openToolsSettingsAutoApprove() {
DispatchQueue.main.async {
activateAndOpenSettings()
- hostAppStore.send(.setActiveTab(.mcp))
+ hostAppStore.send(.setActiveTab(.tools))
+ hostAppStore.send(.setActiveToolsSubTab(.AutoApprove))
}
}
@@ -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()
}
@@ -193,13 +203,25 @@ struct CopilotForXcodeApp: App {
}
DistributedNotificationCenter.default().addObserver(
- forName: .openMCPSettingsWindowRequest,
+ forName: .openToolsSettingsWindowRequest,
+ object: nil,
+ queue: .main
+ ) { _ in
+ DispatchQueue.main.async {
+ activateAndOpenSettings()
+ hostAppStore.send(.setActiveTab(.tools))
+ }
+ }
+
+ DistributedNotificationCenter.default().addObserver(
+ forName: .openToolsSettingsAutoApproveWindowRequest,
object: nil,
queue: .main
) { _ in
DispatchQueue.main.async {
activateAndOpenSettings()
- hostAppStore.send(.setActiveTab(.mcp))
+ hostAppStore.send(.setActiveTab(.tools))
+ hostAppStore.send(.setActiveToolsSubTab(.AutoApprove))
}
}
@@ -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/Copilot-for-Xcode-Info.plist b/Copilot-for-Xcode-Info.plist
index 12d852d9..62815b26 100644
--- a/Copilot-for-Xcode-Info.plist
+++ b/Copilot-for-Xcode-Info.plist
@@ -30,5 +30,7 @@
$(TeamIdentifierPrefix)STANDARD_TELEMETRY_CHANNEL_KEY$(STANDARD_TELEMETRY_CHANNEL_KEY)
+ GITHUB_APP_ID
+ $(GITHUB_APP_ID)
\ 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 cfbc068c..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,51 +50,29 @@ struct ToolCallRequest {
let completion: (AnyJSONRPCResponse) -> Void
}
-public struct FileEdit: Equatable {
+struct ConversationTurnTrackingState {
+ var turnParentMap: [String: String] = [:] // Maps subturn ID to parent turn ID
+ var validConversationIds: Set = [] // Tracks all valid conversation IDs including subagents
- public enum Status: String {
- case none = "none"
- case kept = "kept"
- case undone = "undone"
- }
-
- public let fileURL: URL
- public let originalContent: String
- public var modifiedContent: String
- public var status: Status
-
- /// Different toolName, the different undo logic. Like `insert_edit_into_file` and `create_file`
- public var toolName: ToolName
-
- public init(
- fileURL: URL,
- originalContent: String,
- modifiedContent: String,
- status: Status = .none,
- toolName: ToolName
- ) {
- self.fileURL = fileURL
- self.originalContent = originalContent
- self.modifiedContent = modifiedContent
- self.status = status
- self.toolName = toolName
+ mutating func reset() {
+ turnParentMap.removeAll()
+ validConversationIds.removeAll()
}
}
public final class ChatService: ChatServiceType, ObservableObject {
- public enum RequestType: String, Equatable {
- case conversation, codeReview
- }
-
public var memory: ContextAwareAutoManagedChatMemory
@Published public internal(set) var chatHistory: [ChatMessage] = []
@Published public internal(set) var isReceivingMessage = false
+ @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
@@ -103,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
@@ -155,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() {
@@ -170,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([
@@ -207,35 +205,37 @@ public final class ChatService: ChatServiceType, ObservableObject {
return
}
- copilotTool.invokeTool(request, completion: completion, chatHistoryUpdater: self?.appendToolCallHistory, contextProvider: self)
+ _ = copilotTool.invokeTool(request, completion: completion, contextProvider: self)
}).store(in: &cancellables)
}
- private func appendToolCallHistory(turnId: String, editAgentRounds: [AgentRound]) {
+ func appendToolCallHistory(turnId: String, editAgentRounds: [AgentRound], fileEdits: [FileEdit] = [], parentTurnId: String? = nil) {
let chatTabId = self.chatTabInfo.id
Task {
+ let turnStatus: ChatMessage.TurnStatus? = {
+ guard let round = editAgentRounds.first, let toolCall = round.toolCalls?.first else {
+ return nil
+ }
+
+ switch toolCall.status {
+ case .waitForConfirmation: return .waitForConfirmation
+ case .accepted, .running, .completed, .error: return .inProgress
+ case .cancelled: return .cancelled
+ }
+ }()
+
let message = ChatMessage(
assistantMessageWithId: turnId,
chatTabID: chatTabId,
- editAgentRounds: editAgentRounds
+ editAgentRounds: editAgentRounds,
+ parentTurnId: parentTurnId,
+ fileEdits: fileEdits,
+ turnStatus: turnStatus
)
await self.memory.appendMessage(message)
}
}
-
- public func updateFileEdits(by fileEdit: FileEdit) {
- if let existingFileEdit = self.fileEditMap[fileEdit.fileURL] {
- self.fileEditMap[fileEdit.fileURL] = .init(
- fileURL: fileEdit.fileURL,
- originalContent: existingFileEdit.originalContent,
- modifiedContent: fileEdit.modifiedContent,
- toolName: existingFileEdit.toolName
- )
- } else {
- self.fileEditMap[fileEdit.fileURL] = fileEdit
- }
- }
public func notifyChangeTextDocument(fileURL: URL, content: String, version: Int) async throws {
try await conversationProvider?.notifyChangeTextDocument(fileURL: fileURL, content: content, version: version, workspaceURL: getWorkspaceURL())
@@ -251,88 +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) {
- if status == .cancelled {
- resetOngoingRequest()
- return
- }
-
- // Send the tool call result back to the server
- if let toolCallRequest = self.pendingToolCallRequests[toolCallId], status == .accepted {
+ // Capture the pending request info before removing it from the dictionary
+ let toolCallRequest = self.pendingToolCallRequests[toolCallId]
+
+ // Step 1: Send confirmation response to server (for accept/cancel actions only)
+ if let toolCallRequest = toolCallRequest, status == .accepted || status == .cancelled {
self.pendingToolCallRequests.removeValue(forKey: toolCallId)
- let toolResult = LanguageModelToolConfirmationResult(result: .Accept)
- let jsonResult = try? JSONEncoder().encode(toolResult)
- let jsonValue = (try? JSONDecoder().decode(JSONValue.self, from: jsonResult ?? Data())) ?? JSONValue.null
- toolCallRequest.completion(
- AnyJSONRPCResponse(
- id: toolCallRequest.requestId,
- result: JSONValue.array([
- jsonValue,
- JSONValue.null
- ])
- )
- )
+ sendToolConfirmationResponse(toolCallRequest, accepted: status == .accepted)
}
- // Update the tool call status in the chat history
+ // Step 2: Update the tool call status in chat history UI
Task {
- guard let lastMessage = await memory.history.last, lastMessage.role == .assistant else {
+ guard let targetMessage = await ToolCallStatusUpdater.findMessageContainingToolCall(
+ toolCallRequest,
+ conversationTurnTracking: conversationTurnTracking,
+ history: await memory.history
+ ) else {
return
}
-
- var updatedAgentRounds: [AgentRound] = []
- for i in 0.. Bool {
+ conversationTurnTracking.validConversationIds.contains(conversationId)
+ }
+
+ /// Workaround: toolConfirmation request does not have parent turnId.
+ func parentTurnIdForTurnId(_ turnId: String) -> String? {
+ conversationTurnTracking.turnParentMap[turnId]
+ }
+
+ func storePendingToolCallRequest(toolCallId: String, request: ToolCallRequest) {
+ pendingToolCallRequests[toolCallId] = request
+ }
+
+ /// Sends the confirmation response (accept/dismiss) back to the server
+ func sendToolConfirmationResponse(_ request: ToolCallRequest, accepted: Bool) {
+ let toolResult = LanguageModelToolConfirmationResult(
+ result: accepted ? .Accept : .Dismiss
+ )
+ let jsonResult = try? JSONEncoder().encode(toolResult)
+ let jsonValue = (try? JSONDecoder().decode(JSONValue.self, from: jsonResult ?? Data())) ?? JSONValue.null
+
+ request.completion(
+ AnyJSONRPCResponse(
+ id: request.requestId,
+ result: JSONValue.array([jsonValue, JSONValue.null])
+ )
+ )
+ }
+
public enum ChatServiceError: Error, LocalizedError {
case conflictingImageFormats(String)
@@ -353,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 {
@@ -458,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
@@ -466,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(
@@ -477,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]
@@ -502,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: [])
@@ -524,9 +568,10 @@ public final class ChatService: ChatServiceType, ObservableObject {
print("Failed to cancel ongoing request with WDT: \(activeRequestId)")
}
}
- resetOngoingRequest()
+ resetOngoingRequest(with: .cancelled)
}
+ // Not used
public func clearHistory() async {
let messageIds = await memory.history.map { $0.id }
@@ -542,10 +587,17 @@ public final class ChatService: ChatServiceType, ObservableObject {
deleteAllChatMessagesFromStorage(messageIds)
resetOngoingRequest()
}
-
- public func deleteMessage(id: String) async {
- await memory.removeMessage(id)
- deleteChatMessageFromStorage(id)
+
+ public func deleteMessages(ids: [String]) async {
+ let turnIdsFromMessages = await memory.history
+ .filter { ids.contains($0.id) }
+ .compactMap { $0.clsTurnID }
+ .map { String($0) }
+ let turnIds = Array(Set(turnIdsFromMessages))
+
+ await memory.removeMessages(ids)
+ await deleteTurns(turnIds)
+ deleteAllChatMessagesFromStorage(ids)
}
public func resendMessage(id: String, model: String? = nil, modelProviderName: String? = nil) async throws {
@@ -563,6 +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
)
@@ -680,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 }) {
@@ -705,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)
+ /// 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)
+ }
}
}
@@ -716,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(
@@ -756,39 +875,188 @@ public final class ChatService: ChatServiceType, ObservableObject {
content: messageContent,
references: messageReferences,
steps: messageSteps,
- editAgentRounds: messageAgentRounds
+ 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,
@@ -936,45 +1245,67 @@ public final class ChatService: ChatServiceType, ObservableObject {
requestWithTurns.turns = turns
}
- try await conversationProvider?.createConversation(requestWithTurns, workspaceURL: getWorkspaceURL())
+ return try await conversationProvider?.createConversation(requestWithTurns, workspaceURL: getWorkspaceURL())
}
} catch {
- resetOngoingRequest()
+ resetOngoingRequest(with: .error)
throw error
}
}
- // MARK: - File Edit
- public func undoFileEdit(for fileURL: URL) throws {
- guard let fileEdit = self.fileEditMap[fileURL],
- fileEdit.status == .none
- else { return }
-
- switch fileEdit.toolName {
- case .insertEditIntoFile:
- InsertEditIntoFileTool.applyEdit(for: fileURL, content: fileEdit.originalContent, contextProvider: self)
- case .createFile:
- try CreateFileTool.undo(for: fileURL)
- default:
+ private func deleteTurns(_ turnIds: [String]) async {
+ guard !turnIds.isEmpty, let conversationId = conversationId else {
return
}
- self.fileEditMap[fileURL]!.status = .undone
+ let workspaceURL = getWorkspaceURL()
+
+ for turnId in turnIds {
+ do {
+ try await conversationProvider?
+ .deleteTurn(with: conversationId, turnId: turnId, workspaceURL: workspaceURL)
+ } catch {
+ Logger.client.error("Failed to delete turn: \(error)")
+ }
+ }
}
- public func keepFileEdit(for fileURL: URL) {
- guard let fileEdit = self.fileEditMap[fileURL], fileEdit.status == .none
- else { return }
- self.fileEditMap[fileURL]!.status = .kept
- }
+ // MARK: - Certificate Error Detection
- public func resetFileEdits() {
- self.fileEditMap = [:]
+ /// Checks if an error is related to SSL certificate issues
+ private func isCertificateError(_ error: Error) -> Bool {
+ let errorDescription = error.localizedDescription.lowercased()
+
+ // Check for certificate error messages
+ if errorDescription.contains("unable to get local issuer certificate") ||
+ errorDescription.contains("self-signed certificate in certificate chain") ||
+ errorDescription.contains("unable_to_get_issuer_cert_locally") {
+ return true
+ }
+
+ // Check GitHubCopilotError with ServerError
+ if let serverError = error as? ServerError,
+ case .serverError(_, let message, _) = serverError {
+ let serverMessage = message.lowercased()
+ if serverMessage.contains("unable to get local issuer certificate") ||
+ serverMessage.contains("self-signed certificate in certificate chain") {
+ return true
+ }
+ }
+
+ return false
}
- public func discardFileEdit(for fileURL: URL) throws {
- try self.undoFileEdit(for: fileURL)
- self.fileEditMap.removeValue(forKey: fileURL)
+ private func showCertificateErrorMessage(turnId: String?) async {
+ let messageId = turnId ?? UUID().uuidString
+ let errorMessage = ChatMessage(
+ errorMessageWithId: messageId,
+ chatTabID: chatTabInfo.id,
+ errorMessages: [
+ SSLCertificateErrorMessage
+ ]
+ )
+ await memory.appendMessage(errorMessage)
}
}
@@ -982,6 +1313,7 @@ public final class ChatService: ChatServiceType, ObservableObject {
public final class SharedChatService {
public var chatTemplates: [ChatTemplate]? = nil
public var chatAgents: [ChatAgent]? = nil
+ public var conversationModes: [ConversationMode]? = nil
private let conversationProvider: ConversationServiceProvider?
public static let shared = SharedChatService.service()
@@ -1010,6 +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
@@ -1152,57 +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()
-
- let turnId = UUID().uuidString
-
- await addCodeReviewUserMessage(id: UUID().uuidString, turnId: turnId, group: group)
-
- let initialBotMessage = ChatMessage(
- assistantMessageWithId: turnId,
- chatTabID: chatTabInfo.id
- )
- await memory.appendMessage(initialBotMessage)
-
- guard let projectRootURL = getProjectRootURL()
- else {
- let round = CodeReviewRound.fromError(turnId: turnId, error: "Invalid git repository.")
- await appendCodeReviewRound(round)
- resetOngoingRequest()
- 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)
- } catch {
resetOngoingRequest()
- throw error
+ return
}
+
+ let round: CodeReviewRound = .init(
+ turnId: turnId,
+ status: .waitForConfirmation,
+ request: .from(prChanges)
+ )
+ await appendCodeReviewRound(round, turnStatus: .waitForConfirmation)
}
private func shouldIncludeFileForReview(url: URL) -> Bool {
@@ -1215,9 +1558,15 @@ extension ChatService {
}
}
- private func appendCodeReviewRound(_ round: CodeReviewRound) async {
+ private func appendCodeReviewRound(
+ _ round: CodeReviewRound,
+ turnStatus: ChatMessage.TurnStatus? = nil
+ ) async {
let message = ChatMessage(
- assistantMessageWithId: round.turnId, chatTabID: chatTabInfo.id, codeReviewRound: round
+ assistantMessageWithId: round.turnId,
+ chatTabID: chatTabInfo.id,
+ codeReviewRound: round,
+ turnStatus: turnStatus
)
await memory.appendMessage(message)
@@ -1253,7 +1602,7 @@ extension ChatService {
round.status = .accepted
request.updateSelectedChanges(by: selectedFileUris)
round.request = request
- await appendCodeReviewRound(round)
+ await appendCodeReviewRound(round, turnStatus: .inProgress)
round.status = .running
await appendCodeReviewRound(round)
@@ -1266,7 +1615,7 @@ extension ChatService {
if let errorMessage = errorMessage {
round = round.withError(errorMessage)
await appendCodeReviewRound(round)
- resetOngoingRequest()
+ resetOngoingRequest(with: .error)
return
}
@@ -1290,14 +1639,19 @@ extension ChatService {
round.status = .cancelled
await appendCodeReviewRound(round)
- resetOngoingRequest()
+ resetOngoingRequest(with: .cancelled)
}
private func addCodeReviewUserMessage(id: String, turnId: String, group: GitDiffGroup) async {
let content = group == .index
? "Code review for staged changes."
: "Code review for unstaged changes."
- let chatMessage = ChatMessage(userMessageWithId: id, chatTabId: chatTabInfo.id, content: content)
+ let chatMessage = ChatMessage(
+ userMessageWithId: id,
+ chatTabId: chatTabInfo.id,
+ content: content,
+ requestType: .codeReview
+ )
await memory.appendMessage(chatMessage)
saveChatMessageToStorage(chatMessage)
}
diff --git a/Core/Sources/ChatService/CodeReview/CodeReviewProvider.swift b/Core/Sources/ChatService/CodeReview/CodeReviewProvider.swift
index 2fddf1b3..c41eb61b 100644
--- a/Core/Sources/ChatService/CodeReview/CodeReviewProvider.swift
+++ b/Core/Sources/ChatService/CodeReview/CodeReviewProvider.swift
@@ -49,11 +49,9 @@ public struct CodeReviewProvider {
) async throws -> CodeReviewResult? {
return try await context.conversationServiceProvider?
.reviewChanges(
- .init(
- changes: changes.map {
- .init(uri: $0.uri, path: $0.path, baseContent: $0.baseContent, headContent: $0.headContent)
- }
- )
+ changes.map {
+ .init(uri: $0.uri, path: $0.path, baseContent: $0.baseContent, headContent: $0.headContent)
+ }
)
}
}
diff --git a/Core/Sources/ChatService/Extensions/ChatService+FileEdit.swift b/Core/Sources/ChatService/Extensions/ChatService+FileEdit.swift
new file mode 100644
index 00000000..c901a341
--- /dev/null
+++ b/Core/Sources/ChatService/Extensions/ChatService+FileEdit.swift
@@ -0,0 +1,55 @@
+import Foundation
+import ConversationServiceProvider
+import ChatAPIService
+
+extension ChatService {
+ // MARK: - File Edit
+
+ public func updateFileEdits(by fileEdit: FileEdit) {
+ if let existingFileEdit = self.fileEditMap[fileEdit.fileURL] {
+ self.fileEditMap[fileEdit.fileURL] = .init(
+ fileURL: fileEdit.fileURL,
+ originalContent: existingFileEdit.originalContent,
+ modifiedContent: fileEdit.modifiedContent,
+ toolName: existingFileEdit.toolName
+ )
+ } else {
+ self.fileEditMap[fileEdit.fileURL] = fileEdit
+ }
+ }
+
+ public func undoFileEdit(for fileURL: URL) throws {
+ guard var fileEdit = self.fileEditMap[fileURL],
+ fileEdit.status == .none
+ else { return }
+
+ switch fileEdit.toolName {
+ case .insertEditIntoFile:
+ InsertEditIntoFileTool.applyEdit(for: fileURL, content: fileEdit.originalContent)
+ case .createFile:
+ try CreateFileTool.undo(for: fileURL)
+ default:
+ return
+ }
+
+ fileEdit.status = .undone
+ self.fileEditMap[fileURL] = fileEdit
+ }
+
+ public func keepFileEdit(for fileURL: URL) {
+ guard var fileEdit = self.fileEditMap[fileURL], fileEdit.status == .none
+ else { return }
+
+ fileEdit.status = .kept
+ self.fileEditMap[fileURL] = fileEdit
+ }
+
+ public func resetFileEdits() {
+ self.fileEditMap = [:]
+ }
+
+ public func discardFileEdit(for fileURL: URL) throws {
+ try self.undoFileEdit(for: fileURL)
+ self.fileEditMap.removeValue(forKey: fileURL)
+ }
+}
diff --git a/Core/Sources/ChatService/ToolCalls/AutoApproval/AutoApprovalScope.swift b/Core/Sources/ChatService/ToolCalls/AutoApproval/AutoApprovalScope.swift
new file mode 100644
index 00000000..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/CreateFileTool.swift b/Core/Sources/ChatService/ToolCalls/CreateFileTool.swift
index f811901a..702ade22 100644
--- a/Core/Sources/ChatService/ToolCalls/CreateFileTool.swift
+++ b/Core/Sources/ChatService/ToolCalls/CreateFileTool.swift
@@ -3,6 +3,7 @@ import AppKit
import ConversationServiceProvider
import Foundation
import Logger
+import ChatAPIService
public class CreateFileTool: ICopilotTool {
public static let name = ToolName.createFile
@@ -10,7 +11,6 @@ public class CreateFileTool: ICopilotTool {
public func invokeTool(
_ request: InvokeClientToolRequest,
completion: @escaping (AnyJSONRPCResponse) -> Void,
- chatHistoryUpdater: ChatHistoryUpdater?,
contextProvider: (any ToolContextProvider)?
) -> Bool {
guard let params = request.params,
@@ -50,12 +50,14 @@ public class CreateFileTool: ICopilotTool {
return true
}
- contextProvider?.updateFileEdits(by: .init(
+ let fileEdit: FileEdit = .init(
fileURL: URL(fileURLWithPath: filePath),
originalContent: "",
modifiedContent: writtenContent,
toolName: CreateFileTool.name
- ))
+ )
+
+ contextProvider?.updateFileEdits(by: fileEdit)
NSWorkspace.openFileInXcode(fileURL: URL(fileURLWithPath: filePath)) { _, error in
if let error = error {
@@ -78,9 +80,7 @@ public class CreateFileTool: ICopilotTool {
)
]
- if let chatHistoryUpdater {
- chatHistoryUpdater(params.turnId, editAgentRounds)
- }
+ contextProvider?.updateChatHistory(params.turnId, editAgentRounds: editAgentRounds, fileEdits: [fileEdit])
completeResponse(
request,
diff --git a/Core/Sources/ChatService/ToolCalls/FetchWebPageTool.swift b/Core/Sources/ChatService/ToolCalls/FetchWebPageTool.swift
index 5ff5f6b9..c9f95260 100644
--- a/Core/Sources/ChatService/ToolCalls/FetchWebPageTool.swift
+++ b/Core/Sources/ChatService/ToolCalls/FetchWebPageTool.swift
@@ -14,7 +14,6 @@ public class FetchWebPageTool: ICopilotTool {
public func invokeTool(
_ request: InvokeClientToolRequest,
completion: @escaping (AnyJSONRPCResponse) -> Void,
- chatHistoryUpdater: ChatHistoryUpdater?,
contextProvider: (any ToolContextProvider)?
) -> Bool {
guard let params = request.params,
diff --git a/Core/Sources/ChatService/ToolCalls/GetErrorsTool.swift b/Core/Sources/ChatService/ToolCalls/GetErrorsTool.swift
index f95625dc..3a464016 100644
--- a/Core/Sources/ChatService/ToolCalls/GetErrorsTool.swift
+++ b/Core/Sources/ChatService/ToolCalls/GetErrorsTool.swift
@@ -8,7 +8,6 @@ public class GetErrorsTool: ICopilotTool {
public func invokeTool(
_ request: InvokeClientToolRequest,
completion: @escaping (AnyJSONRPCResponse) -> Void,
- chatHistoryUpdater: ChatHistoryUpdater?,
contextProvider: ToolContextProvider?
) -> Bool {
guard let params = request.params,
@@ -34,9 +33,11 @@ public class GetErrorsTool: ICopilotTool {
/// As the resolving should be sync. Especially when completion the JSONRPCResponse
let focusedElement: AXUIElement? = try? xcodeInstance.appElement.copyValue(key: kAXFocusedUIElementAttribute)
let focusedEditor: SourceEditor?
- if let editorElement = focusedElement, editorElement.isSourceEditor {
+ if let editorElement = focusedElement, editorElement.isNonNavigatorSourceEditor {
focusedEditor = .init(runningApplication: xcodeInstance.runningApplication, element: editorElement)
- } else if let element = focusedElement, let editorElement = element.firstParent(where: \.isSourceEditor) {
+ } else if let element = focusedElement, let editorElement = element.firstParent(
+ where: \.isNonNavigatorSourceEditor
+ ) {
focusedEditor = .init(runningApplication: xcodeInstance.runningApplication, element: editorElement)
} else {
focusedEditor = nil
diff --git a/Core/Sources/ChatService/ToolCalls/GetTerminalOutputTool.swift b/Core/Sources/ChatService/ToolCalls/GetTerminalOutputTool.swift
index 1d298711..69a76689 100644
--- a/Core/Sources/ChatService/ToolCalls/GetTerminalOutputTool.swift
+++ b/Core/Sources/ChatService/ToolCalls/GetTerminalOutputTool.swift
@@ -4,7 +4,7 @@ import JSONRPC
import Terminal
public class GetTerminalOutputTool: ICopilotTool {
- public func invokeTool(_ request: InvokeClientToolRequest, completion: @escaping (AnyJSONRPCResponse) -> Void, chatHistoryUpdater: ChatHistoryUpdater?, contextProvider: (any ToolContextProvider)?) -> Bool {
+ public func invokeTool(_ request: InvokeClientToolRequest, completion: @escaping (AnyJSONRPCResponse) -> Void, contextProvider: (any ToolContextProvider)?) -> Bool {
var result: String = ""
if let input = request.params?.input as? [String: AnyCodable], let terminalId = input["id"]?.value as? String{
let session = TerminalSessionManager.shared.getSession(for: terminalId)
diff --git a/Core/Sources/ChatService/ToolCalls/ICopilotTool.swift b/Core/Sources/ChatService/ToolCalls/ICopilotTool.swift
index cbe9e2ec..8e10fbfa 100644
--- a/Core/Sources/ChatService/ToolCalls/ICopilotTool.swift
+++ b/Core/Sources/ChatService/ToolCalls/ICopilotTool.swift
@@ -2,15 +2,16 @@ import ChatTab
import ConversationServiceProvider
import Foundation
import JSONRPC
+import ChatAPIService
public protocol ToolContextProvider {
// MARK: insert_edit_into_file
var chatTabInfo: ChatTabInfo { get }
func updateFileEdits(by fileEdit: FileEdit) -> Void
func notifyChangeTextDocument(fileURL: URL, content: String, version: Int) async throws
+ func updateChatHistory(_ turnId: String, editAgentRounds: [AgentRound], fileEdits: [FileEdit])
}
-public typealias ChatHistoryUpdater = (String, [AgentRound]) -> Void
public protocol ICopilotTool {
/**
@@ -18,7 +19,6 @@ public protocol ICopilotTool {
* - Parameters:
* - request: The tool invocation request.
* - completion: Closure called with JSON-RPC response when tool execution completes.
- * - chatHistoryUpdater: Optional closure to update chat history during tool execution.
* - contextProvider: Optional provider that supplies additional context information
* needed for tool execution, such as chat tab data and file editing capabilities.
* - Returns: Boolean indicating if the tool call has completed. True if the tool call is completed, false otherwise.
@@ -26,7 +26,6 @@ public protocol ICopilotTool {
func invokeTool(
_ request: InvokeClientToolRequest,
completion: @escaping (AnyJSONRPCResponse) -> Void,
- chatHistoryUpdater: ChatHistoryUpdater?,
contextProvider: ToolContextProvider?
) -> Bool
}
@@ -85,4 +84,8 @@ extension ICopilotTool {
}
}
-extension ChatService: ToolContextProvider { }
+extension ChatService: ToolContextProvider {
+ public func updateChatHistory(_ turnId: String, editAgentRounds: [AgentRound], fileEdits: [FileEdit] = []) {
+ appendToolCallHistory(turnId: turnId, editAgentRounds: editAgentRounds, fileEdits: fileEdits)
+ }
+}
diff --git a/Core/Sources/ChatService/ToolCalls/InsertEditIntoFileTool.swift b/Core/Sources/ChatService/ToolCalls/InsertEditIntoFileTool.swift
index db89c57c..2eb6b160 100644
--- a/Core/Sources/ChatService/ToolCalls/InsertEditIntoFileTool.swift
+++ b/Core/Sources/ChatService/ToolCalls/InsertEditIntoFileTool.swift
@@ -6,6 +6,35 @@ import Foundation
import JSONRPC
import Logger
import XcodeInspector
+import ChatAPIService
+import SystemUtils
+import Workspace
+
+public enum InsertEditError: LocalizedError {
+ case missingEditorElement(file: URL)
+ case openingApplicationUnavailable
+ case fileNotOpenedInXcode
+ case fileURLMismatch(expected: URL, actual: URL?)
+ case fileNotAccessible(URL)
+ case fileHasUnsavedChanges(URL)
+
+ public var errorDescription: String? {
+ switch self {
+ case .missingEditorElement(let file):
+ return "Could not find source editor element for file \(file.lastPathComponent)."
+ case .openingApplicationUnavailable:
+ return "Failed to get the application that opened the file."
+ case .fileNotOpenedInXcode:
+ return "The file is not currently opened in Xcode."
+ case .fileURLMismatch(let expected, let actual):
+ return "The currently focused file URL \(actual?.lastPathComponent ?? "unknown") does not match the expected file URL \(expected.lastPathComponent)."
+ case .fileNotAccessible(let fileURL):
+ return "The file \(fileURL.lastPathComponent) is not accessible."
+ case .fileHasUnsavedChanges(let fileURL):
+ return "The file \(fileURL.lastPathComponent) seems to have unsaved changes in Xcode. Please save the file and try again."
+ }
+ }
+}
public class InsertEditIntoFileTool: ICopilotTool {
public static let name = ToolName.insertEditIntoFile
@@ -13,7 +42,6 @@ public class InsertEditIntoFileTool: ICopilotTool {
public func invokeTool(
_ request: InvokeClientToolRequest,
completion: @escaping (AnyJSONRPCResponse) -> Void,
- chatHistoryUpdater: ChatHistoryUpdater?,
contextProvider: (any ToolContextProvider)?
) -> Bool {
guard let params = request.params,
@@ -30,7 +58,7 @@ public class InsertEditIntoFileTool: ICopilotTool {
let fileURL = URL(fileURLWithPath: filePath)
let originalContent = try String(contentsOf: fileURL, encoding: .utf8)
- InsertEditIntoFileTool.applyEdit(for: fileURL, content: code, contextProvider: contextProvider) { newContent, error in
+ InsertEditIntoFileTool.applyEdit(for: fileURL, content: code) { newContent, error in
if let error = error {
self.completeResponse(
request,
@@ -47,9 +75,8 @@ public class InsertEditIntoFileTool: ICopilotTool {
return
}
- contextProvider.updateFileEdits(
- by: .init(fileURL: fileURL, originalContent: originalContent, modifiedContent: code, toolName: InsertEditIntoFileTool.name)
- )
+ let fileEdit: FileEdit = .init(fileURL: fileURL, originalContent: originalContent, modifiedContent: code, toolName: InsertEditIntoFileTool.name)
+ contextProvider.updateFileEdits(by: fileEdit)
let editAgentRounds: [AgentRound] = [
.init(
@@ -66,9 +93,8 @@ public class InsertEditIntoFileTool: ICopilotTool {
)
]
- if let chatHistoryUpdater {
- chatHistoryUpdater(params.turnId, editAgentRounds)
- }
+ contextProvider
+ .updateChatHistory(params.turnId, editAgentRounds: editAgentRounds, fileEdits: [fileEdit])
self.completeResponse(request, response: newContent, completion: completion)
}
@@ -88,18 +114,11 @@ public class InsertEditIntoFileTool: ICopilotTool {
public static func applyEdit(
for fileURL: URL,
content: String,
- contextProvider: any ToolContextProvider,
xcodeInstance: AppInstanceInspector
) throws -> String {
- // Get the focused element directly from the app (like XcodeInspector does)
- guard let focusedElement: AXUIElement = try? xcodeInstance.appElement.copyValue(key: kAXFocusedUIElementAttribute)
+ guard let editorElement = Self.getEditorElement(by: xcodeInstance, for: fileURL)
else {
- throw NSError(domain: "Failed to access xcode element", code: 0)
- }
-
- // Find the source editor element using XcodeInspector's logic
- guard let editorElement = focusedElement.findSourceEditorElement() else {
- throw NSError(domain: "Could not find source editor element", code: 0)
+ throw InsertEditError.missingEditorElement(file: fileURL)
}
// Check if element supports kAXValueAttribute before reading
@@ -115,10 +134,9 @@ public class InsertEditIntoFileTool: ICopilotTool {
let lines = value.components(separatedBy: .newlines)
- var isInjectedSuccess = false
- var injectionError: Error?
-
do {
+ try Self.checkOpenedFileURL(for: fileURL, xcodeInstance: xcodeInstance)
+
try AXHelper().injectUpdatedCodeWithAccessibilityAPI(
.init(
content: content,
@@ -130,48 +148,132 @@ public class InsertEditIntoFileTool: ICopilotTool {
.inserted(0, [content])
]
),
- focusElement: editorElement,
- onSuccess: {
- Logger.client.info("Content injection succeeded")
- isInjectedSuccess = true
- },
- onError: {
- Logger.client.error("Content injection failed in onError callback")
- }
+ focusElement: editorElement
)
} catch {
- Logger.client.error("Content injection threw error: \(error)")
- if let axError = error as? AXError {
- Logger.client.error("AX Error code during injection: \(axError.rawValue)")
+ Logger.client.error("Failed to inject code for insert edit into file: \(error.localizedDescription)")
+ throw error
+ }
+
+ // Verify the content was applied by reading it back
+ return try Self.getCurrentEditorContent(for: fileURL, by: xcodeInstance)
+ }
+
+ public static func applyEdit(
+ for fileURL: URL,
+ content: String,
+ completion: ((String?, Error?) -> Void)? = nil
+ ) {
+ if SystemUtils.isDeveloperMode || SystemUtils.isPrereleaseBuild {
+ /// Experimental solution: Use file system write for better reliability. Only enable in dev mode or prerelease builds.
+ Self.applyEditWithFileSystem(
+ for: fileURL,
+ content: content,
+ completion: completion
+ )
+ } else {
+ Self.applyEditWithAccessibilityAPI(
+ for: fileURL,
+ content: content,
+ completion: completion
+ )
+ }
+ }
+
+ /// Get the source editor element with retries for specific file URL
+ private static func getEditorElement(
+ by xcodeInstance: AppInstanceInspector,
+ for fileURL: URL,
+ retryTimes: Int = 6,
+ delay: TimeInterval = 0.5
+ ) -> AXUIElement? {
+ var remainingAttempts = max(1, retryTimes)
+
+ while remainingAttempts > 0 {
+ guard let realtimeURL = xcodeInstance.appElement.realtimeDocumentURL,
+ realtimeURL == fileURL,
+ let focusedElement = xcodeInstance.appElement.focusedElement,
+ let editorElement = focusedElement.findSourceEditorElement()
+ else {
+ if remainingAttempts > 1 {
+ Thread.sleep(forTimeInterval: delay)
+ }
+
+ remainingAttempts -= 1
+ continue
}
- injectionError = error
+
+ return editorElement
}
- if !isInjectedSuccess {
- let errorMessage = injectionError?.localizedDescription ?? "Failed to apply edit"
- Logger.client.error("Edit application failed: \(errorMessage)")
- throw NSError(domain: "Failed to apply edit: \(errorMessage)", code: 0)
+ Logger.client.error("Editor element not found for \(fileURL.lastPathComponent) after \(retryTimes) attempts.")
+ return nil
+ }
+
+ // Check if current opened file is the target URL
+ private static func checkOpenedFileURL(
+ for fileURL: URL,
+ xcodeInstance: AppInstanceInspector
+ ) throws {
+ let realtimeDocumentURL = xcodeInstance.realtimeDocumentURL
+
+ if realtimeDocumentURL != fileURL {
+ throw InsertEditError.fileURLMismatch(expected: fileURL, actual: realtimeDocumentURL)
+ }
+ }
+
+ private static func getCurrentEditorContent(for fileURL: URL, by xcodeInstance: AppInstanceInspector) throws -> String {
+ guard let editorElement = getEditorElement(by: xcodeInstance, for: fileURL, retryTimes: 1)
+ else {
+ throw InsertEditError.missingEditorElement(file: fileURL)
}
- // Verify the content was applied by reading it back
+ return try editorElement.copyValue(key: kAXValueAttribute)
+ }
+}
+
+private extension AppInstanceInspector {
+ var realtimeDocumentURL: URL? {
+ appElement.realtimeDocumentURL
+ }
+}
+
+extension InsertEditIntoFileTool {
+ static func applyEditWithFileSystem(
+ for fileURL: URL,
+ content: String,
+ completion: ((String?, Error?) -> Void)? = nil
+ ) {
do {
- let newContent: String = try editorElement.copyValue(key: kAXValueAttribute)
- Logger.client.info("Successfully read back new content, length: \(newContent.count)")
- return newContent
- } catch {
- Logger.client.error("Failed to read back new content: \(error)")
- if let axError = error as? AXError {
- Logger.client.error("AX Error code when reading back: \(axError.rawValue)")
+ guard let diskFileContent = try? String(contentsOf: fileURL) else {
+ throw InsertEditError.fileNotAccessible(fileURL)
}
- throw error
+
+ if let focusedElement = XcodeInspector.shared.focusedElement,
+ focusedElement.isNonNavigatorSourceEditor,
+ focusedElement.realtimeDocumentURL == fileURL,
+ focusedElement.value != diskFileContent
+ {
+ throw InsertEditError.fileHasUnsavedChanges(fileURL)
+ }
+
+ // write content to disk
+ try content.write(to: fileURL, atomically: true, encoding: .utf8)
+
+ Task { @WorkspaceActor in
+ await WorkspaceInvocationCoordinator().invokeFilespaceUpdate(fileURL: fileURL, content: content)
+ if let completion = completion { completion(content, nil) }
+ }
+ } catch {
+ if let completion = completion { completion(nil, error) }
+ Logger.client.info("Failed to apply edit for file at \(fileURL), \(error)")
}
}
- public static func applyEdit(
+ static func applyEditWithAccessibilityAPI(
for fileURL: URL,
content: String,
- contextProvider: any ToolContextProvider,
- completion: ((String?, Error?) -> Void)? = nil
+ completion: ((String?, Error?) -> Void)? = nil,
) {
NSWorkspace.openFileInXcode(fileURL: fileURL) { app, error in
do {
@@ -179,25 +281,23 @@ public class InsertEditIntoFileTool: ICopilotTool {
guard let app = app
else {
- throw NSError(domain: "Failed to get the app that opens file.", code: 0)
+ throw InsertEditError.openingApplicationUnavailable
}
let appInstanceInspector = AppInstanceInspector(runningApplication: app)
guard appInstanceInspector.isXcode
else {
- throw NSError(domain: "The file is not opened in Xcode.", code: 0)
+ throw InsertEditError.fileNotOpenedInXcode
}
let newContent = try applyEdit(
for: fileURL,
content: content,
- contextProvider: contextProvider,
xcodeInstance: appInstanceInspector
)
Task {
- // Force to notify the CLS about the new change within the document before edit_file completion.
- try? await contextProvider.notifyChangeTextDocument(fileURL: fileURL, content: newContent, version: 0)
+ await WorkspaceInvocationCoordinator().invokeFilespaceUpdate(fileURL: fileURL, content: newContent)
if let completion = completion { completion(newContent, nil) }
}
} catch {
diff --git a/Core/Sources/ChatService/ToolCalls/RunInTerminalTool.swift b/Core/Sources/ChatService/ToolCalls/RunInTerminalTool.swift
index fba3e4a0..1fc8306b 100644
--- a/Core/Sources/ChatService/ToolCalls/RunInTerminalTool.swift
+++ b/Core/Sources/ChatService/ToolCalls/RunInTerminalTool.swift
@@ -4,7 +4,7 @@ import XcodeInspector
import JSONRPC
public class RunInTerminalTool: ICopilotTool {
- public func invokeTool(_ request: InvokeClientToolRequest, completion: @escaping (AnyJSONRPCResponse) -> Void, chatHistoryUpdater: ChatHistoryUpdater?, contextProvider: (any ToolContextProvider)?) -> Bool {
+ public func invokeTool(_ request: InvokeClientToolRequest, completion: @escaping (AnyJSONRPCResponse) -> Void, contextProvider: (any ToolContextProvider)?) -> Bool {
let params = request.params!
Task {
diff --git a/Core/Sources/ChatService/ToolCalls/ToolCallStatusUpdater.swift b/Core/Sources/ChatService/ToolCalls/ToolCallStatusUpdater.swift
new file mode 100644
index 00000000..b8058ccb
--- /dev/null
+++ b/Core/Sources/ChatService/ToolCalls/ToolCallStatusUpdater.swift
@@ -0,0 +1,104 @@
+import ChatAPIService
+import ConversationServiceProvider
+import Foundation
+
+/// Helper methods for updating tool call status in chat history
+/// Handles both main turn tool calls and subagent tool calls
+struct ToolCallStatusUpdater {
+ /// Finds the message containing the tool call, handling both main turns and subturns
+ static func findMessageContainingToolCall(
+ _ toolCallRequest: ToolCallRequest?,
+ conversationTurnTracking: ConversationTurnTrackingState,
+ history: [ChatMessage]
+ ) async -> ChatMessage? {
+ guard let request = toolCallRequest else { return nil }
+
+ // If this is a subturn, find the parent turn; otherwise use the request's turnId
+ let turnIdToFind = conversationTurnTracking.turnParentMap[request.turnId] ?? request.turnId
+
+ return history.first(where: { $0.id == turnIdToFind && $0.role == .assistant })
+ }
+
+ /// Searches for a tool call in agent rounds (including nested subagent rounds) and creates an update
+ ///
+ /// Note: Parent turns can have multiple sequential subturns, but they don't appear simultaneously.
+ /// Subturns are merged into the parent's last round's subAgentRounds array by ChatMemory.
+ static func findAndUpdateToolCall(
+ toolCallId: String,
+ newStatus: AgentToolCall.ToolCallStatus,
+ in agentRounds: [AgentRound]
+ ) -> AgentRound? {
+ // First, search in main rounds (for regular tool calls)
+ for round in agentRounds {
+ if let toolCalls = round.toolCalls {
+ for toolCall in toolCalls where toolCall.id == toolCallId {
+ return AgentRound(
+ roundId: round.roundId,
+ reply: "",
+ toolCalls: [
+ AgentToolCall(
+ id: toolCallId,
+ name: toolCall.name,
+ toolType: toolCall.toolType,
+ status: newStatus
+ ),
+ ]
+ )
+ }
+ }
+ }
+
+ // If not found in main rounds, search in subagent rounds (for subturn tool calls)
+ // Subturns are nested under the parent round's subAgentRounds
+ for round in agentRounds {
+ guard let subAgentRounds = round.subAgentRounds else { continue }
+
+ for subRound in subAgentRounds {
+ guard let toolCalls = subRound.toolCalls else { continue }
+
+ for toolCall in toolCalls where toolCall.id == toolCallId {
+ // Create an update that will be merged into the parent's subAgentRounds
+ // ChatMemory.appendMessage will handle the merging logic
+ let subagentRound = AgentRound(
+ roundId: subRound.roundId,
+ reply: "",
+ toolCalls: [
+ AgentToolCall(
+ id: toolCallId,
+ name: toolCall.name,
+ toolType: toolCall.toolType,
+ status: newStatus
+ ),
+ ]
+ )
+ return AgentRound(
+ roundId: round.roundId,
+ reply: "",
+ toolCalls: [],
+ subAgentRounds: [subagentRound]
+ )
+ }
+ }
+ }
+
+ return nil
+ }
+
+ /// Creates a message update with the new tool call status
+ static func createMessageUpdate(
+ targetMessage: ChatMessage,
+ updatedRound: AgentRound
+ ) -> ChatMessage {
+ return ChatMessage(
+ id: targetMessage.id,
+ chatTabID: targetMessage.chatTabID,
+ clsTurnID: targetMessage.clsTurnID,
+ role: .assistant,
+ content: "",
+ references: [],
+ steps: [],
+ editAgentRounds: [updatedRound],
+ turnStatus: .inProgress
+ )
+ }
+}
diff --git a/Core/Sources/ChatService/WorkspaceInvocationCoordinator.swift b/Core/Sources/ChatService/WorkspaceInvocationCoordinator.swift
new file mode 100644
index 00000000..c7b28d16
--- /dev/null
+++ b/Core/Sources/ChatService/WorkspaceInvocationCoordinator.swift
@@ -0,0 +1,11 @@
+import Foundation
+import Workspace
+import Dependencies
+
+struct WorkspaceInvocationCoordinator {
+ @Dependency(\.workspaceInvoker) private var workspaceInvoker
+
+ func invokeFilespaceUpdate(fileURL: URL, content: String) async {
+ await workspaceInvoker.invokeFilespaceUpdate(fileURL, content)
+ }
+}
diff --git a/Core/Sources/ConversationTab/Chat.swift b/Core/Sources/ConversationTab/Chat.swift
index 547ad026..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,9 +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,
@@ -43,9 +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
+ codeReviewRound: CodeReviewRound? = nil,
+ fileEdits: [FileEdit] = [],
+ turnStatus: ChatMessage.TurnStatus? = nil,
+ requestType: RequestType,
+ modelName: String? = nil,
+ modelProviderName: String? = nil,
+ billingMultiplier: Float? = nil,
+ reasoningEffort: String? = nil
) {
self.id = id
self.role = role
@@ -56,9 +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
}
}
@@ -77,6 +105,10 @@ struct ChatContext: Equatable {
self.attachedImages = attachedImages
}
+ static func empty() -> ChatContext {
+ .init(typedMessage: "", attachedReferences: [], attachedImages: [])
+ }
+
static func from(_ message: DisplayedChatMessage, projectURL: URL) -> ChatContext {
.init(
typedMessage: message.text,
@@ -147,45 +179,411 @@ struct ChatContextProvider: Equatable {
@Reducer
struct Chat {
public typealias MessageID = String
+ public enum EditorMode: Hashable {
+ case input // Default input mode
+ case editUserMessage(MessageID)
+
+ var isDefault: Bool { self == .input }
+
+ var isEditingUserMessage: Bool {
+ switch self {
+ case .input: false
+ case .editUserMessage: true
+ }
+ }
+
+ var editingUserMessageId: String? {
+ switch self {
+ case .input: nil
+ case .editUserMessage(let messageID): messageID
+ }
+ }
+ }
+
+ @ObservableState
+ struct EditorState: Equatable {
+ enum Field: String, Hashable {
+ case textField
+ case fileSearchBar
+ }
+
+ var codeReviewState = ConversationCodeReviewFeature.State()
+
+ var mode: EditorMode
+ var contexts: [EditorMode: ChatContext]
+ var contextProvider: ChatContextProvider
+ var focusedField: Field?
+ var currentEditor: ConversationFileReference?
+ var handOffClicked: Bool = false
+
+ init(
+ mode: EditorMode = .input,
+ contexts: [EditorMode: ChatContext] = [.input: .empty()],
+ contextProvider: ChatContextProvider = .init(),
+ focusedField: Field? = nil,
+ currentEditor: ConversationFileReference? = nil,
+ handOffClicked: Bool = false
+ ) {
+ self.mode = mode
+ self.contexts = contexts
+ self.contextProvider = contextProvider
+ self.focusedField = focusedField
+ self.currentEditor = currentEditor
+ self.handOffClicked = handOffClicked
+ }
+
+ func context(for mode: EditorMode) -> ChatContext {
+ contexts[mode] ?? .empty()
+ }
+
+ mutating func setContext(_ context: ChatContext, for mode: EditorMode) {
+ contexts[mode] = context
+ }
+
+ mutating func updateCurrentContext(_ update: (inout ChatContext) -> Void) {
+ var context = self.context(for: mode)
+ update(&context)
+ setContext(context, for: mode)
+ }
+
+ mutating func keepOnlyInputContext() {
+ let inputContext = context(for: .input)
+ contexts = [.input: inputContext]
+ }
+
+ mutating func clearAttachedImages() {
+ updateCurrentContext { $0.attachedImages.removeAll() }
+ }
+
+ mutating func addReference(_ reference: ConversationAttachedReference) {
+ updateCurrentContext { context in
+ guard !context.attachedReferences.contains(reference) else { return }
+ context.attachedReferences.append(reference)
+ }
+ }
+
+ mutating func removeReference(_ reference: ConversationAttachedReference) {
+ updateCurrentContext { context in
+ guard let index = context.attachedReferences.firstIndex(of: reference) else { return }
+ context.attachedReferences.remove(at: index)
+ }
+ }
+
+ mutating func addImage(_ image: ImageReference) {
+ updateCurrentContext { context in
+ guard !context.attachedImages.contains(image) else { return }
+ context.attachedImages.append(image)
+ }
+ }
+
+ mutating func removeImage(_ image: ImageReference) {
+ updateCurrentContext { context in
+ guard let index = context.attachedImages.firstIndex(of: image) else { return }
+ context.attachedImages.remove(at: index)
+ }
+ }
+
+ mutating func pushContext(_ context: ChatContext) {
+ contextProvider.pushContext(context)
+ }
+
+ mutating func resetContextProvider() {
+ contextProvider.reset()
+ }
+
+ mutating func popNextContext() -> ChatContext? {
+ contextProvider.getNextContext()
+ }
+
+ func previousContext(from history: [DisplayedChatMessage], projectURL: URL) -> ChatContext? {
+ contextProvider.getPreviousContext(from: history, projectURL: projectURL)
+ }
+ }
+
+ @ObservableState
+ struct ConversationState: Equatable {
+ var history: [DisplayedChatMessage]
+ var isReceivingMessage: Bool
+ var isSummarizingConversation: Bool
+ var requestType: RequestType?
+ var contextSizeInfo: ContextSizeInfo?
+
+ init(
+ history: [DisplayedChatMessage] = [],
+ isReceivingMessage: Bool = false,
+ 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] {
+ guard let index = history.firstIndex(where: { $0.id == messageId }) else {
+ return []
+ }
+ return Array(history[(index + 1)...])
+ }
+
+ func editUserMessageEffectedMessages(for mode: EditorMode) -> [DisplayedChatMessage] {
+ guard case .editUserMessage(let messageId) = mode else {
+ return []
+ }
+ return subsequentMessages(after: messageId)
+ }
+ }
+
+ struct AgentEditingState: Equatable {
+ var fileEditMap: OrderedDictionary
+ var diffViewerController: DiffViewWindowController?
+
+ init(
+ fileEditMap: OrderedDictionary = [:],
+ diffViewerController: DiffViewWindowController? = nil
+ ) {
+ self.fileEditMap = fileEditMap
+ self.diffViewerController = diffViewerController
+ }
+
+ static func == (lhs: AgentEditingState, rhs: AgentEditingState) -> Bool {
+ lhs.fileEditMap == rhs.fileEditMap && lhs.diffViewerController === rhs.diffViewerController
+ }
+ }
+
+ struct EnvironmentState: Equatable {
+ var isAgentMode: Bool
+ var workspaceURL: URL?
+ var selectedAgent: ConversationMode
+
+ init(
+ isAgentMode: Bool = AppState.shared.isAgentModeEnabled(),
+ workspaceURL: URL? = nil,
+ selectedAgent: ConversationMode = .defaultAgent
+ ) {
+ self.isAgentMode = isAgentMode
+ self.workspaceURL = workspaceURL
+ self.selectedAgent = selectedAgent
+ }
+ }
@ObservableState
struct State: Equatable {
+ typealias Field = EditorState.Field
+
// Not use anymore. the title of history tab will get from chat tab info
// Keep this var as `ChatTabItemView` reference this
- var title: String = "New Chat"
- var chatContext: ChatContext = .init(typedMessage: "", attachedReferences: [], attachedImages: [])
- var contextProvider: ChatContextProvider = .init()
- var history: [DisplayedChatMessage] = []
- var isReceivingMessage = false
- var requestType: ChatService.RequestType? = nil
- var chatMenu = ChatMenu.State()
- var focusedField: Field?
- var currentEditor: ConversationFileReference? = nil
+ var title: String
+ var editor: EditorState
+ var conversation: ConversationState
+ var agentEditing: AgentEditingState
+ var environment: EnvironmentState
+ var chatMenu: ChatMenu.State
+ var codeReviewState: ConversationCodeReviewFeature.State
+
+ init(
+ title: String = "New Chat",
+ editor: EditorState = .init(),
+ conversation: ConversationState = .init(),
+ agentEditing: AgentEditingState = .init(),
+ environment: EnvironmentState = .init(),
+ chatMenu: ChatMenu.State = .init(),
+ codeReviewState: ConversationCodeReviewFeature.State = .init()
+ ) {
+ self.title = title
+ self.editor = editor
+ self.conversation = conversation
+ self.agentEditing = agentEditing
+ self.environment = environment
+ self.chatMenu = chatMenu
+ self.codeReviewState = codeReviewState
+ }
+
+ init(
+ title: String = "New Chat",
+ editorMode: EditorMode = .input,
+ editorModeContexts: [EditorMode: ChatContext] = [.input: .empty()],
+ focusedField: Field? = nil,
+ history: [DisplayedChatMessage] = [],
+ isReceivingMessage: Bool = false,
+ requestType: RequestType? = nil,
+ fileEditMap: OrderedDictionary = [:],
+ diffViewerController: DiffViewWindowController? = nil,
+ isAgentMode: Bool = AppState.shared.isAgentModeEnabled(),
+ workspaceURL: URL? = nil,
+ selectedAgent: ConversationMode = .defaultAgent,
+ chatMenu: ChatMenu.State = .init(),
+ codeReviewState: ConversationCodeReviewFeature.State = .init()
+ ) {
+ self.init(
+ title: title,
+ editor: EditorState(
+ mode: editorMode,
+ contexts: editorModeContexts,
+ focusedField: focusedField
+ ),
+ conversation: ConversationState(
+ history: history,
+ isReceivingMessage: isReceivingMessage,
+ requestType: requestType
+ ),
+ agentEditing: AgentEditingState(
+ fileEditMap: fileEditMap,
+ diffViewerController: diffViewerController
+ ),
+ environment: EnvironmentState(
+ isAgentMode: isAgentMode,
+ workspaceURL: workspaceURL,
+ selectedAgent: selectedAgent
+ ),
+ chatMenu: chatMenu,
+ codeReviewState: codeReviewState
+ )
+ }
+
+ var editorMode: EditorMode {
+ get { editor.mode }
+ set {
+ editor.mode = newValue
+ if editor.contexts[newValue] == nil {
+ editor.contexts[newValue] = .empty()
+ }
+ }
+ }
+
+ var chatContext: ChatContext {
+ get { editor.context(for: editor.mode) }
+ set { editor.setContext(newValue, for: editor.mode) }
+ }
+
+ var history: [DisplayedChatMessage] {
+ get { conversation.history }
+ set { conversation.history = newValue }
+ }
+
+ var isReceivingMessage: Bool {
+ get { conversation.isReceivingMessage }
+ set { conversation.isReceivingMessage = newValue }
+ }
+
+ var 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 }
+ }
+
+ var currentEditor: ConversationFileReference? {
+ get { editor.currentEditor }
+ set { editor.currentEditor = newValue }
+ }
+
var attachedReferences: [ConversationAttachedReference] {
chatContext.attachedReferences
}
+
var attachedImages: [ImageReference] {
chatContext.attachedImages
}
+
var typedMessage: String {
get { chatContext.typedMessage }
- set {
- chatContext.typedMessage = newValue
- // User typed in. Need to reset contextProvider
- contextProvider.reset()
+ set {
+ editor.updateCurrentContext { $0.typedMessage = newValue }
+ editor.resetContextProvider()
}
}
- /// Cache the original content
- var fileEditMap: OrderedDictionary = [:]
- var diffViewerController: DiffViewWindowController? = nil
- var isAgentMode: Bool = AppState.shared.isAgentModeEnabled()
- var workspaceURL: URL? = nil
- enum Field: String, Hashable {
- case textField
- case fileSearchBar
+
+ var fileEditMap: OrderedDictionary {
+ get { agentEditing.fileEditMap }
+ set { agentEditing.fileEditMap = newValue }
+ }
+
+ var diffViewerController: DiffViewWindowController? {
+ get { agentEditing.diffViewerController }
+ set { agentEditing.diffViewerController = newValue }
+ }
+
+ var isAgentMode: Bool {
+ get { environment.isAgentMode }
+ set { environment.isAgentMode = newValue }
+ }
+
+ var workspaceURL: URL? {
+ get { environment.workspaceURL }
+ set { environment.workspaceURL = newValue }
+ }
+
+ var selectedAgent: ConversationMode {
+ get { environment.selectedAgent }
+ set { environment.selectedAgent = newValue }
+ }
+
+ /// Not including the one being edited
+ var editUserMessageEffectedMessages: [DisplayedChatMessage] {
+ conversation.editUserMessageEffectedMessages(for: editor.mode)
}
- var codeReviewState = ConversationCodeReviewFeature.State()
+ // The following messages after check point message will hide on ChatPanel
+ var pendingCheckpointMessageId: String? = nil
+ // The chat context before the first restoring
+ var pendingCheckpointContext: ChatContext? = nil
+ var messagesAfterCheckpoint: [DisplayedChatMessage] {
+ guard let pendingCheckpointMessageId, let index = history.firstIndex(where: { $0.id == pendingCheckpointMessageId }) else {
+ return []
+ }
+
+ let nextIndex = index + 1
+ guard nextIndex < history.count else {
+ return []
+ }
+
+ // The order matters for restoring / redoing file edits
+ return Array(history[nextIndex...])
+ }
+
+ func getMessages(after afterMessageId: String, through throughMessageId: String?) -> [DisplayedChatMessage] {
+ guard let afterMessageIdIndex = history.firstIndex(where: { $0.id == afterMessageId }) else {
+ return []
+ }
+
+ let startIndex = afterMessageIdIndex + 1
+
+ let endIndex: Int
+ if let throughMessageId = throughMessageId,
+ let throughMessageIdIndex = history.firstIndex(where: { $0.id == throughMessageId }) {
+ endIndex = throughMessageIdIndex + 1
+ } else {
+ endIndex = history.count
+ }
+
+ guard startIndex < endIndex, startIndex < history.count else {
+ return []
+ }
+
+ return Array(history[startIndex..)
case agentModeChanged(Bool)
+ case selectedAgentChanged(ConversationMode)
// Code Review
case codeReview(ConversationCodeReviewFeature.Action)
@@ -254,6 +659,15 @@ struct Chat {
// External Action
case observeFixErrorNotification
case fixEditorErrorIssue(EditorErrorIssue)
+
+ // Check Point
+ case restoreCheckPoint(String)
+ case restoreFileEdits
+ case undoCheckPoint // Revert the restore
+ case discardCheckPoint
+ case reloadWorkingset(DisplayedChatMessage)
+
+ case openAutoApproveSettings
}
let service: ChatService
@@ -264,6 +678,7 @@ struct Chat {
case observeIsReceivingMessageChange(UUID)
case sendMessage(UUID)
case observeFileEditChange(UUID)
+ case observeContextSizeInfoChange(UUID)
case observeFixErrorNotification(UUID)
}
@@ -293,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))
+ }
}
}
@@ -318,17 +745,33 @@ 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
let attachedImages: [ImageReference] = shouldAttachImages ? state.attachedImages : []
let references = state.attachedReferences
- state.chatContext.attachedImages = []
+ state.editor.clearAttachedImages()
+
+ let toDeleteMessageIds: [String] = {
+ var messageIds: [String] = []
+ if state.editorMode.isEditingUserMessage {
+ messageIds.append(contentsOf: state.editUserMessageEffectedMessages.map { $0.id })
+ if let editingUserMessageId = state.editorMode.editingUserMessageId {
+ messageIds.append(editingUserMessageId)
+ }
+ }
+ return messageIds
+ }()
return .run { send in
await send(.resetContextProvider)
+ await send(.discardCheckPoint)
+ await service.deleteMessages(ids: toDeleteMessageIds)
+ await send(.setEditorMode(.input))
try await service
.send(
@@ -339,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))
@@ -349,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
@@ -372,9 +828,11 @@ struct Chat {
)?.modelFamily
let references = state.attachedReferences
let agentMode = AppState.shared.isAgentModeEnabled()
+ let selectedAgentSubMode = AppState.shared.getSelectedAgentSubMode()
return .run { send in
await send(.resetContextProvider)
+ await send(.discardCheckPoint)
try await service
.send(
@@ -384,14 +842,73 @@ 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"
return .none
+
+ case let .updateTypedMessage(message):
+ state.typedMessage = message
+ return .none
+
+ case let .setEditorMode(mode):
+
+ switch mode {
+ case .input:
+ state.editorMode = mode
+ // remove all edit contexts except input mode
+ state.editor.keepOnlyInputContext()
+ case .editUserMessage(let messageID):
+ guard let message = state.history.first(where: { $0.id == messageID }),
+ message.role == .user,
+ let projectURL = service.getProjectRootURL()
+ else {
+ return .none
+ }
+
+ let chatContext: ChatContext = .from(message, projectURL: projectURL)
+ state.editor.setContext(chatContext, for: mode)
+ state.editorMode = mode
+ let isReceivingMessage = service.isReceivingMessage
+
+ return .run { send in
+ if isReceivingMessage {
+ await send(.stopRespondingButtonTapped)
+ }
+ }
+ }
+
+ return .none
case .stopRespondingButtonTapped:
return .merge(
@@ -408,7 +925,7 @@ struct Chat {
case let .deleteMessageButtonTapped(id):
return .run { _ in
- await service.deleteMessage(id: id)
+ await service.deleteMessages(ids: [id])
}
case let .resendMessageButtonTapped(id):
@@ -433,9 +950,11 @@ struct Chat {
"/bin/bash",
arguments: [
"-c",
- "xed -l 0 \"\(reference.filePath)\"",
+ "xed -l 0 \"${TARGET_CHAT_FILE}\"",
],
- environment: [:]
+ environment: [
+ "TARGET_CHAT_FILE": reference.filePath
+ ]
)
} catch {
print(error)
@@ -454,6 +973,7 @@ struct Chat {
await send(.observeHistoryChange)
await send(.observeIsReceivingMessageChange)
await send(.observeFileEditChange)
+ await send(.observeContextSizeInfoChange)
}
case .observeHistoryChange:
@@ -479,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()
}
@@ -513,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]()
@@ -539,9 +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
+ codeReviewRound: message.codeReviewRound,
+ fileEdits: message.fileEdits,
+ turnStatus: message.turnStatus,
+ requestType: message.requestType,
+ modelName: message.modelName,
+ modelProviderName: message.modelProviderName,
+ billingMultiplier: message.billingMultiplier,
+ reasoningEffort: message.reasoningEffort
))
return all
@@ -551,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
@@ -620,27 +1174,21 @@ struct Chat {
state.currentEditor = fileReference
return .none
case let .addReference(ref):
- guard !state.chatContext.attachedReferences.contains(ref) else {
- return .none
- }
- state.chatContext.attachedReferences.append(ref)
+ state.editor.addReference(ref)
return .none
case let .removeReference(ref):
- guard let index = state.chatContext.attachedReferences.firstIndex(of: ref) else {
- return .none
- }
- state.chatContext.attachedReferences.remove(at: index)
+ state.editor.removeReference(ref)
return .none
// MARK: - Image Context
case let .addSelectedImage(imageReference):
guard !state.attachedImages.contains(imageReference) else { return .none }
- state.chatContext.attachedImages.append(imageReference)
+ state.editor.addImage(imageReference)
return .run { send in await send(.resetContextProvider) }
case let .removeSelectedImage(imageReference):
- guard let index = state.attachedImages.firstIndex(of: imageReference) else { return .none }
- state.chatContext.attachedImages.remove(at: index)
+ guard let _ = state.attachedImages.firstIndex(of: imageReference) else { return .none }
+ state.editor.removeImage(imageReference)
return .run { send in await send(.resetContextProvider) }
// MARK: - Agent Edits
@@ -690,13 +1238,24 @@ struct Chat {
case let .agentModeChanged(isAgentMode):
state.isAgentMode = isAgentMode
return .none
+
+ case let .selectedAgentChanged(mode):
+ state.selectedAgent = mode
+ state.handOffClicked = false
+ return .none
+
+ // MARK: - Code Review
+ case .codeReview(.request(_)):
+ return .run { send in
+ await send(.discardCheckPoint)
+ }
case .codeReview:
return .none
// MARK: Chat Context
case .reloadNextContext:
- guard let context = state.contextProvider.getNextContext() else {
+ guard let context = state.editor.popNextContext() else {
return .none
}
@@ -708,7 +1267,7 @@ struct Chat {
case .reloadPreviousContext:
guard let projectURL = service.getProjectRootURL(),
- let context = state.contextProvider.getPreviousContext(
+ let context = state.editor.previousContext(
from: state.history,
projectURL: projectURL)
else {
@@ -717,14 +1276,14 @@ struct Chat {
let currentContext = state.chatContext
state.chatContext = context
- state.contextProvider.pushContext(currentContext)
+ state.editor.pushContext(currentContext)
return .run { send in
await send(.focusOnTextField)
}
case .resetContextProvider:
- state.contextProvider.reset()
+ state.editor.resetContextProvider()
return .none
// MARK: - External action
@@ -777,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,
@@ -786,10 +1347,155 @@ 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))
+
+ // MARK: - Check Point
+
+ case let .restoreCheckPoint(messageId):
+ guard let message = state.history.first(where: { $0.id == messageId }) else {
+ return .none
+ }
+
+ if state.pendingCheckpointContext == nil {
+ state.pendingCheckpointContext = state.chatContext
+ }
+ state.pendingCheckpointMessageId = messageId
+
+ // Reload the chat context
+ let messagesAfterCheckpoint = state.messagesAfterCheckpoint
+ if !messagesAfterCheckpoint.isEmpty,
+ let userMessage = messagesAfterCheckpoint.first,
+ userMessage.role == .user,
+ let projectURL = service.getProjectRootURL()
+ {
+ state.chatContext = .from(userMessage, projectURL: projectURL)
+ }
+
+ let isReceivingMessage = state.isReceivingMessage
+ return .run { send in
+ await send(.restoreFileEdits)
+ await send(.reloadWorkingset(message))
+ if isReceivingMessage {
+ await send(.stopRespondingButtonTapped)
+ }
+ }
+
+ case .restoreFileEdits:
+ // Revert file edits in messages after checkpoint
+ let messagesAfterCheckpoint = state.messagesAfterCheckpoint
+ guard !messagesAfterCheckpoint.isEmpty else {
+ return .none
+ }
+
+ return .run { _ in
+ var restoredURLs = Set()
+ let fileManager = FileManager.default
+
+ // Revert the file edit. From the oldest to newest
+ for message in messagesAfterCheckpoint {
+ let fileEdits = message.fileEdits
+ guard !fileEdits.isEmpty else {
+ continue
+ }
+
+ for fileEdit in fileEdits {
+ guard !restoredURLs.contains(fileEdit.fileURL) else {
+ continue
+ }
+ restoredURLs.insert(fileEdit.fileURL)
+
+ do {
+ switch fileEdit.toolName {
+ case .createFile:
+ try fileManager.removeItem(at: fileEdit.fileURL)
+ case .insertEditIntoFile:
+ try fileEdit.originalContent.write(to: fileEdit.fileURL, atomically: true, encoding: .utf8)
+ default:
+ break
+ }
+ } catch {
+ Logger.client.error(">>> Failed to restore file Edit: \(error)")
+ }
+ }
+ }
+ }
+
+ case .undoCheckPoint:
+ if let context = state.pendingCheckpointContext {
+ state.chatContext = context
+ state.pendingCheckpointContext = nil
+ }
+ let reversedMessagesAfterCheckpoint = Array(state.messagesAfterCheckpoint.reversed())
+
+ state.pendingCheckpointMessageId = nil
+
+ // Redo file edits in messages after checkpoint
+ guard !reversedMessagesAfterCheckpoint.isEmpty else {
+ return .none
+ }
+
+ return .run { send in
+ var redoedURL = Set()
+ let lastMessage = reversedMessagesAfterCheckpoint.first
+
+ for message in reversedMessagesAfterCheckpoint {
+ let fileEdits = message.fileEdits
+ guard !fileEdits.isEmpty else {
+ continue
+ }
+
+ for fileEdit in fileEdits {
+ guard !redoedURL.contains(fileEdit.fileURL) else {
+ continue
+ }
+ redoedURL.insert(fileEdit.fileURL)
+
+ do {
+ switch fileEdit.toolName {
+ case .createFile, .insertEditIntoFile:
+ try fileEdit.modifiedContent.write(to: fileEdit.fileURL, atomically: true, encoding: .utf8)
+ default:
+ break
+ }
+ } catch {
+ Logger.client.error(">>> failed to undo fileEdit: \(error)")
+ }
+ }
+ }
+
+ // Recover fileEdits working set
+ if let lastMessage {
+ await send(.reloadWorkingset(lastMessage))
+ }
+ }
+
+ case .discardCheckPoint:
+ let messagesAfterCheckpoint = state.messagesAfterCheckpoint
+ state.pendingCheckpointMessageId = nil
+ state.pendingCheckpointContext = nil
+ return .run { _ in
+ if !messagesAfterCheckpoint.isEmpty {
+ await service.deleteMessages(ids: messagesAfterCheckpoint.map { $0.id })
+ }
+ }
+
+ case let .reloadWorkingset(message):
+ return .run { _ in
+ service.resetFileEdits()
+ for fileEdit in message.fileEdits {
+ service.updateFileEdits(by: fileEdit)
+ }
+ }
+
+ case .openAutoApproveSettings:
+ return .run { _ in
+ try launchHostAppToolsSettingsAutoApprove()
+ }
}
}
}
diff --git a/Core/Sources/ConversationTab/ChatContextMenu.swift b/Core/Sources/ConversationTab/ChatContextMenu.swift
index 3e1ac095..cf1e5f76 100644
--- a/Core/Sources/ConversationTab/ChatContextMenu.swift
+++ b/Core/Sources/ConversationTab/ChatContextMenu.swift
@@ -79,6 +79,7 @@ struct ChatContextMenu: View {
store.send(.customCommandButtonTapped(command))
}) {
Text(command.name)
+ .scaledFont(.body)
}
}
}
diff --git a/Core/Sources/ConversationTab/ChatExtension.swift b/Core/Sources/ConversationTab/ChatExtension.swift
index 8949b882..f5e2573f 100644
--- a/Core/Sources/ConversationTab/ChatExtension.swift
+++ b/Core/Sources/ConversationTab/ChatExtension.swift
@@ -15,4 +15,12 @@ extension Chat.State {
)
return [CurrentEditorSkill(currentFile: fileReference), ProblemsInActiveDocumentSkill()]
}
+
+ func getChatContext(of mode: Chat.EditorMode) -> ChatContext {
+ return editor.context(for: mode)
+ }
+
+ func getSubsequentMessages(after messageId: String) -> [DisplayedChatMessage] {
+ conversation.subsequentMessages(after: messageId)
+ }
}
diff --git a/Core/Sources/ConversationTab/ChatPanel.swift b/Core/Sources/ConversationTab/ChatPanel.swift
index f4a9edd8..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 {
@@ -35,30 +36,50 @@ public struct ChatPanel: View {
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
- .padding(.trailing, 16)
} else {
ChatPanelMessages(chat: chat)
.accessibilityElement(children: .combine)
.accessibilityLabel("Chat Messages Group")
-
- if let _ = chat.history.last?.followUp {
+
+ if chat.isAgentMode, let handOffs = chat.selectedAgent.handOffs, !handOffs.isEmpty,
+ chat.history.contains(where: { $0.role == .assistant && $0.turnStatus != .inProgress }),
+ !chat.handOffClicked {
+ ChatHandOffs(chat: chat)
+ .scaledPadding(.vertical, 8)
+ .scaledPadding(.horizontal, 16)
+ .dimWithExitEditMode(chat)
+ } else if let _ = chat.history.last?.followUp {
ChatFollowUp(chat: chat)
- .padding(.trailing, 16)
- .padding(.vertical, 8)
+ .scaledPadding(.vertical, 8)
+ .scaledPadding(.horizontal, 16)
+ .dimWithExitEditMode(chat)
}
}
+ if 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)
- .padding(.trailing, 16)
+ .dimWithExitEditMode(chat)
+ .scaledPadding(.horizontal, 24)
}
-
- ChatPanelInputArea(chat: chat)
- .padding(.trailing, 16)
+
+ ChatPanelInputArea(chat: chat, r: r, editorMode: .input)
+ .dimWithExitEditMode(chat)
+ .scaledPadding(.horizontal, 16)
}
- .padding(.leading, 16)
- .padding(.bottom, 16)
- .background(Color(nsColor: .windowBackgroundColor))
+ .scaledPadding(.vertical, 12)
+ .background(Color.chatWindowBackgroundColor)
.onAppear {
chat.send(.appear)
}
@@ -127,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()
@@ -140,20 +191,39 @@ struct ChatPanelMessages: View {
@State var didScrollToBottomOnAppearOnce = false
@State var isBottomHidden = true
@Environment(\.isEnabled) var isEnabled
+ @AppStorage(\.fontScale) private var fontScale: Double
var body: some View {
WithPerceptionTracking {
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)
- .listItemTint(.clear)
+ .fixedSize(horizontal: false, vertical: true)
ExtraSpacingInResponding(chat: chat)
- Spacer(minLength: 12)
+ Color.clear
+ .frame(height: 12)
.id(bottomID)
.onAppear {
isBottomHidden = false
@@ -172,25 +242,16 @@ struct ChatPanelMessages: View {
value: offset
)
})
+
+ Spacer(minLength: 0)
}
- .modify { view in
- if #available(macOS 13.0, *) {
- view
- .listRowSeparator(.hidden)
- } else {
- view
- }
- }
- }
- .padding(.leading, -8)
- .listStyle(.plain)
- .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(
@@ -207,6 +268,7 @@ struct ChatPanelMessages: View {
}
.overlay(alignment: .bottomTrailing) {
scrollToBottomButton(proxy: proxy)
+ .scaledPadding(4)
}
.background {
PinToBottomHandler(
@@ -253,12 +315,21 @@ struct ChatPanelMessages: View {
.store(in: &cancellable)
}
+ private let listRowSpacing: CGFloat = 32
+ private let scrollButtonBuffer: CGFloat = 32
+
@MainActor
func updatePinningState() {
// where does the 32 come from?
withAnimation(.linear(duration: 0.1)) {
- isScrollToBottomButtonDisplayed = scrollOffset > listHeight + 32 + 20
- || scrollOffset <= 0
+ // Ensure listHeight is greater than 0 to avoid invalid calculations or division by zero.
+ // This guard clause prevents unnecessary updates when the list height is not yet determined.
+ guard listHeight > 0 else {
+ isScrollToBottomButtonDisplayed = false
+ return
+ }
+
+ isScrollToBottomButtonDisplayed = scrollOffset > listHeight + (listRowSpacing + scrollButtonBuffer) * fontScale
}
}
@@ -271,19 +342,18 @@ struct ChatPanelMessages: View {
}
}) {
Image(systemName: "chevron.down")
- .padding(8)
+ .scaledFrame(width: 12, height: 12)
+ .scaledPadding(4)
.background {
Circle()
- .fill(.thickMaterial)
- .shadow(color: .black.opacity(0.2), radius: 2)
+ .fill(Color.chatWindowBackgroundColor)
}
.overlay {
Circle().stroke(Color(nsColor: .separatorColor), lineWidth: 1)
}
.foregroundStyle(.secondary)
}
- .buttonStyle(HoverButtonStyle(padding: 0))
- .padding(4)
+ .buttonStyle(.plain)
.keyboardShortcut(.downArrow, modifiers: [.command])
.opacity(isScrollToBottomButtonDisplayed ? 1 : 0)
.help("Scroll Down")
@@ -291,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)
}
}
}
@@ -322,7 +394,16 @@ struct ChatPanelMessages: View {
}
}
} else {
- Task { pinnedToBottom = false }
+ Task {
+ // Scoll to bottom when `isReceiving` changes to `false`
+ if pinnedToBottom {
+ await Task.yield()
+ withAnimation(.easeInOut(duration: 0.1)) {
+ scrollToBottom()
+ }
+ }
+ pinnedToBottom = false
+ }
}
}
.onChange(of: chat.history.last) { _ in
@@ -332,8 +413,10 @@ struct ChatPanelMessages: View {
}
Task {
await Task.yield()
- withAnimation(.easeInOut(duration: 0.1)) {
- scrollToBottom()
+ if !chat.editorMode.isEditingUserMessage {
+ withAnimation(.easeInOut(duration: 0.1)) {
+ scrollToBottom()
+ }
}
}
}
@@ -351,21 +434,63 @@ struct ChatPanelMessages: View {
struct ChatHistory: View {
let chat: StoreOf
+
+ var filteredHistory: [DisplayedChatMessage] {
+ guard let pendingCheckpointMessageId = chat.pendingCheckpointMessageId else {
+ return chat.history
+ }
+
+ if let checkPointMessageIndex = chat.history.firstIndex(where: { $0.id == pendingCheckpointMessageId }) {
+ return Array(chat.history.prefix(checkPointMessageIndex + 1))
+ }
+
+ return chat.history
+ }
+
+ var editUserMessageEffectedMessageIds: Set {
+ Set(chat.editUserMessageEffectedMessages.map { $0.id })
+ }
var body: some View {
WithPerceptionTracking {
- ForEach(Array(chat.history.enumerated()), id: \.element.id) { index, message in
- VStack(spacing: 0) {
- WithPerceptionTracking {
- ChatHistoryItem(chat: chat, message: message)
- .id(message.id)
- .padding(.top, 4)
- .padding(.bottom, 12)
+ let currentFilteredHistory = filteredHistory
+ let pendingCheckpointMessageId = chat.pendingCheckpointMessageId
+
+ VStack(spacing: 16) {
+ ForEach(Array(currentFilteredHistory.enumerated()), id: \.element.id) { index, message in
+ VStack(spacing: 8) {
+ WithPerceptionTracking {
+ ChatHistoryItem(chat: chat, message: message)
+ .id(message.id)
+ }
+
+ if message.role != .ignored && index < currentFilteredHistory.count - 1 {
+ if message.role == .assistant && message.parentTurnId == nil {
+ let nextMessage = currentFilteredHistory[index + 1]
+ let hasContent = !message.text.isEmpty || !message.editAgentRounds.isEmpty
+ let nextIsNotSubturn = nextMessage.parentTurnId != message.id
+
+ if hasContent && nextIsNotSubturn {
+ CheckPoint(chat: chat, messageId: message.id)
+ .padding(.vertical, 8)
+ .padding(.trailing, 8)
+ }
+ }
+ }
+
+ // Show up check point for redo
+ if message.id == pendingCheckpointMessageId {
+ CheckPoint(chat: chat, messageId: message.id)
+ .padding(.vertical, 8)
+ .padding(.trailing, 8)
+ }
}
-
- // add divider between messages
- if message.role != .ignored && index < chat.history.count - 1 {
- Divider() }
+ .dimWithExitEditMode(
+ chat,
+ applyTo: message.id,
+ isDimmed: editUserMessageEffectedMessageIds.contains(message.id),
+ allowTapToExit: chat.editorMode.isEditingUserMessage && chat.editorMode.editingUserMessageId != message.id
+ )
}
}
}
@@ -385,21 +510,18 @@ struct ChatHistoryItem: View {
id: message.id,
text: text,
imageReferences: message.imageReferences,
- chat: chat
+ chat: chat,
+ editorCornerRadius: r,
+ requestType: message.requestType
)
+ .scaledPadding(.leading, chat.editorMode.isEditingUserMessage && chat.editorMode.editingUserMessageId == message.id ? 0 : 20)
+ .scaledPadding(.trailing, 8)
case .assistant:
BotMessage(
- id: message.id,
- text: text,
- references: message.references,
- followUp: message.followUp,
- errorMessages: message.errorMessages,
- chat: chat,
- steps: message.steps,
- editAgentRounds: message.editAgentRounds,
- panelMessages: message.panelMessages,
- codeReviewRound: message.codeReviewRound
+ message: message,
+ chat: chat
)
+ .scaledPadding(.trailing, 20)
case .ignored:
EmptyView()
}
@@ -420,21 +542,65 @@ struct ChatFollowUp: View {
}) {
HStack(spacing: 4) {
Image(systemName: "sparkles")
+ .scaledFont(.body)
.foregroundColor(.blue)
Text(followUp.message)
- .font(.system(size: chatFontSize))
+ .scaledFont(size: chatFontSize)
.foregroundColor(.blue)
}
}
.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)
@@ -471,629 +637,6 @@ struct ChatCLSError: View {
}
}
-struct ChatPanelInputArea: View {
- let chat: StoreOf
- @FocusState var focusedField: Chat.State.Field?
-
- var body: some View {
- HStack {
- InputAreaTextEditor(chat: chat, focusedField: $focusedField)
- }
- .background(Color.clear)
- }
-
- @MainActor
- var clearButton: some View {
- Button(action: {
- chat.send(.clearButtonTap)
- }) {
- Group {
- if #available(macOS 13.0, *) {
- Image(systemName: "eraser.line.dashed.fill")
- } else {
- Image(systemName: "trash.fill")
- }
- }
- .padding(6)
- .background {
- Circle().fill(Color(nsColor: .controlBackgroundColor))
- }
- .overlay {
- Circle().stroke(Color(nsColor: .controlColor), lineWidth: 1)
- }
- }
- .buttonStyle(.plain)
- }
-
- enum ShowingType { case template, agent }
-
- struct InputAreaTextEditor: View {
- @Perception.Bindable var chat: StoreOf
- var focusedField: FocusState.Binding
- @State var cancellable = Set()
- @State private var isFilePickerPresented = false
- @State private var allFiles: [ConversationAttachedReference]? = nil
- @State private var filteredTemplates: [ChatTemplate] = []
- @State private var filteredAgent: [ChatAgent] = []
- @State private var showingTemplates = false
- @State private var dropDownShowingType: ShowingType? = nil
- @State private var textEditorState: TextEditorState? = nil
-
- @AppStorage(\.enableCurrentEditorContext) var enableCurrentEditorContext: Bool
- @State private var isCurrentEditorContextEnabled: Bool = UserDefaults.shared.value(
- for: \.enableCurrentEditorContext
- )
- @ObservedObject private var status: StatusObserver = .shared
- @State private var isCCRFFEnabled: Bool
- @State private var cancellables = Set()
-
- init(
- chat: StoreOf,
- focusedField: FocusState.Binding
- ) {
- self.chat = chat
- self.focusedField = focusedField
- self.isCCRFFEnabled = FeatureFlagNotifierImpl.shared.featureFlags.ccr
- }
-
- var isRequestingConversation: Bool {
- if chat.isReceivingMessage,
- let requestType = chat.requestType,
- requestType == .conversation {
- return true
- }
- return false
- }
-
- var isRequestingCodeReview: Bool {
- if chat.isReceivingMessage,
- let requestType = chat.requestType,
- requestType == .codeReview {
- return true
- }
-
- return false
- }
-
- var body: some View {
- WithPerceptionTracking {
- VStack(spacing: 0) {
- chatContextView
-
- if isFilePickerPresented {
- FilePicker(
- allFiles: $allFiles,
- workspaceURL: chat.workspaceURL,
- onSubmit: { ref in
- chat.send(.addReference(ref))
- },
- onExit: {
- isFilePickerPresented = false
- focusedField.wrappedValue = .textField
- }
- )
- .onAppear() {
- allFiles = ContextUtils.getFilesFromWorkspaceIndex(workspaceURL: chat.workspaceURL)
- }
- }
-
- if !chat.state.attachedImages.isEmpty {
- ImagesScrollView(chat: chat)
- }
-
- ZStack(alignment: .topLeading) {
- if chat.typedMessage.isEmpty {
- Group {
- chat.isAgentMode ?
- Text("Edit files in your workspace in agent mode") :
- Text("Ask Copilot or type / for commands")
- }
- .font(.system(size: 14))
- .foregroundColor(Color(nsColor: .placeholderTextColor))
- .padding(8)
- .padding(.horizontal, 4)
- }
-
- HStack(spacing: 0) {
- AutoresizingCustomTextEditor(
- text: $chat.typedMessage,
- font: .systemFont(ofSize: 14),
- isEditable: true,
- maxHeight: 400,
- onSubmit: {
- if (dropDownShowingType == nil) {
- submitChatMessage()
- }
- dropDownShowingType = nil
- },
- onTextEditorStateChanged: { (state: TextEditorState?) in
- DispatchQueue.main.async {
- textEditorState = state
- }
- }
- )
- .focused(focusedField, equals: .textField)
- .bind($chat.focusedField, to: focusedField)
- .padding(8)
- .fixedSize(horizontal: false, vertical: true)
- .onChange(of: chat.typedMessage) { newValue in
- Task {
- await onTypedMessageChanged(newValue: newValue)
- }
- }
- /// When chat mode changed, the chat tamplate and agent need to be reloaded
- .onChange(of: chat.isAgentMode) { _ in
- Task {
- await onTypedMessageChanged(newValue: chat.typedMessage)
- }
- }
- }
- .frame(maxWidth: .infinity)
- }
- .padding(.top, 4)
-
- HStack(spacing: 0) {
- ModelPicker()
-
- Spacer()
-
- codeReviewButton
- .buttonStyle(HoverButtonStyle(padding: 0))
- .disabled(isRequestingConversation)
-
- ZStack {
- sendButton
- .opacity(isRequestingConversation ? 0 : 1)
-
- stopButton
- .opacity(isRequestingConversation ? 1 : 0)
- }
- .buttonStyle(HoverButtonStyle(padding: 0))
- .disabled(isRequestingCodeReview)
- }
- .padding(8)
- .padding(.top, -4)
- }
- .overlay(alignment: .top) {
- dropdownOverlay
- }
- .onAppear() {
- subscribeToActiveDocumentChangeEvent()
- // Check quota for CCR
- Task {
- if status.quotaInfo == nil,
- let service = try? GitHubCopilotViewModel.shared.getGitHubCopilotAuthService() {
- _ = try? await service.checkQuota()
- }
- }
- }
- .task {
- subscribeToFeatureFlagsDidChangeEvent()
- }
- .background {
- RoundedRectangle(cornerRadius: 6)
- .fill(Color(nsColor: .controlBackgroundColor))
- }
- .overlay {
- RoundedRectangle(cornerRadius: 6)
- .stroke(Color(nsColor: .controlColor), lineWidth: 1)
- }
- .background {
- Button(action: {
- chat.send(.returnButtonTapped)
- }) {
- EmptyView()
- }
- .keyboardShortcut(KeyEquivalent.return, modifiers: [.shift])
- .accessibilityHidden(true)
-
- Button(action: {
- focusedField.wrappedValue = .textField
- }) {
- EmptyView()
- }
- .keyboardShortcut("l", modifiers: [.command])
- .accessibilityHidden(true)
-
- buildReloadContextButtons()
- }
-
- }
- }
-
- private var reloadNextContextButton: some View {
- Button(action: {
- chat.send(.reloadNextContext)
- }) {
- EmptyView()
- }
- .keyboardShortcut(KeyEquivalent.downArrow, modifiers: [])
- .accessibilityHidden(true)
- }
-
- private var reloadPreviousContextButton: some View {
- Button(action: {
- chat.send(.reloadPreviousContext)
- }) {
- EmptyView()
- }
- .keyboardShortcut(KeyEquivalent.upArrow, modifiers: [])
- .accessibilityHidden(true)
- }
-
- @ViewBuilder
- private func buildReloadContextButtons() -> some View {
- if let textEditorState = textEditorState {
- switch textEditorState {
- case .empty, .singleLine:
- ZStack {
- reloadPreviousContextButton
- reloadNextContextButton
- }
- case .multipleLines(let cursorAt):
- switch cursorAt {
- case .first:
- reloadPreviousContextButton
- case .last:
- reloadNextContextButton
- case .middle:
- EmptyView()
- }
- }
- } else {
- EmptyView()
- }
- }
-
- private var sendButton: some View {
- Button(action: {
- submitChatMessage()
- }) {
- Image(systemName: "paperplane.fill")
- .padding(4)
- }
- .keyboardShortcut(KeyEquivalent.return, modifiers: [])
- .help("Send")
- }
-
- private var stopButton: some View {
- Button(action: {
- chat.send(.stopRespondingButtonTapped)
- }) {
- Image(systemName: "stop.circle")
- .padding(4)
- }
- }
-
- private var isFreeUser: Bool {
- guard let quotaInfo = status.quotaInfo else { return true }
-
- return quotaInfo.isFreeUser
- }
-
- private var ccrDisabledTooltip: String {
- if !isCCRFFEnabled {
- return "GitHub Copilot Code Review is disabled by org policy. Contact your admin."
- }
-
- return "GitHub Copilot Code Review is temporarily unavailable."
- }
-
- var codeReviewIcon: some View {
- Image("codeReview")
- .padding(6)
- }
-
- private var codeReviewButton: some View {
- Group {
- if isFreeUser {
- // Show nothing
- } else if isCCRFFEnabled {
- ZStack {
- stopButton
- .opacity(isRequestingCodeReview ? 1 : 0)
- .help("Stop Code Review")
-
- Menu {
- Button(action: {
- chat.send(.codeReview(.request(.index)))
- }) {
- Text("Review Staged Changes")
- }
-
- Button(action: {
- chat.send(.codeReview(.request(.workingTree)))
- }) {
- Text("Review Unstaged Changes")
- }
- } label: {
- codeReviewIcon
- }
- .opacity(isRequestingCodeReview ? 0 : 1)
- .help("Code Review")
- }
- .buttonStyle(HoverButtonStyle(padding: 0))
- } else {
- codeReviewIcon
- .foregroundColor(Color(nsColor: .tertiaryLabelColor))
- .help(ccrDisabledTooltip)
- }
- }
- }
-
- private func subscribeToFeatureFlagsDidChangeEvent() {
- FeatureFlagNotifierImpl.shared.featureFlagsDidChange
- .sink(receiveValue: { isCCRFFEnabled = $0.ccr })
- .store(in: &cancellables)
- }
-
- private var dropdownOverlay: some View {
- Group {
- if dropDownShowingType != nil {
- if dropDownShowingType == .template {
- ChatDropdownView(items: $filteredTemplates, prefixSymbol: "/") { template in
- chat.typedMessage = "/" + template.id + " "
- if template.id == "releaseNotes" {
- submitChatMessage()
- }
- }
- } else if dropDownShowingType == .agent {
- ChatDropdownView(items: $filteredAgent, prefixSymbol: "@") { agent in
- chat.typedMessage = "@" + agent.id + " "
- }
- }
- }
- }
- }
-
- func onTypedMessageChanged(newValue: String) async {
- if newValue.hasPrefix("/") {
- filteredTemplates = await chatTemplateCompletion(text: newValue)
- dropDownShowingType = filteredTemplates.isEmpty ? nil : .template
- } else if newValue.hasPrefix("@") && !chat.isAgentMode {
- filteredAgent = await chatAgentCompletion(text: newValue)
- dropDownShowingType = filteredAgent.isEmpty ? nil : .agent
- } else {
- dropDownShowingType = nil
- }
- }
-
- enum ChatContextButtonType { case imageAttach, contextAttach}
-
- private var chatContextView: some View {
- let buttonItems: [ChatContextButtonType] = [.contextAttach, .imageAttach]
- let currentEditorItem: [ConversationFileReference] = [chat.state.currentEditor].compactMap {
- $0
- }
- let references = chat.state.attachedReferences
- let chatContextItems: [Any] = buttonItems.map {
- $0 as ChatContextButtonType
- } + currentEditorItem + references
- return FlowLayout(mode: .scrollable, items: chatContextItems, itemSpacing: 4) { item in
- if let buttonType = item as? ChatContextButtonType {
- if buttonType == .imageAttach {
- VisionMenuView(chat: chat)
- } else if buttonType == .contextAttach {
- // File picker button
- Button(action: {
- withAnimation {
- isFilePickerPresented.toggle()
- if !isFilePickerPresented {
- focusedField.wrappedValue = .textField
- }
- }
- }) {
- Image(systemName: "paperclip")
- .resizable()
- .aspectRatio(contentMode: .fill)
- .frame(width: 16, height: 16)
- .padding(4)
- .foregroundColor(.primary.opacity(0.85))
- .font(Font.system(size: 11, weight: .semibold))
- }
- .buttonStyle(HoverButtonStyle(padding: 0))
- .help("Add Context")
- .cornerRadius(6)
- }
- } else if let select = item as? ConversationFileReference, select.isCurrentEditor {
- makeCurrentEditorView(select)
- } else if let select = item as? ConversationAttachedReference {
- makeReferenceItemView(select)
- }
- }
- .padding(.horizontal, 8)
- .padding(.top, 8)
- }
-
- @ViewBuilder
- func makeCurrentEditorView(_ ref: ConversationFileReference) -> some View {
- HStack(spacing: 0) {
- makeContextFileNameView(url: ref.url, isCurrentEditor: true, selection: ref.selection)
-
- Toggle("", isOn: $isCurrentEditorContextEnabled)
- .toggleStyle(SwitchToggleStyle(tint: .blue))
- .controlSize(.mini)
- .padding(.trailing, 4)
- .onChange(of: isCurrentEditorContextEnabled) { newValue in
- enableCurrentEditorContext = newValue
- }
- }
- .chatContextReferenceStyle(isCurrentEditor: true, r: r)
- }
-
- @ViewBuilder
- func makeReferenceItemView(_ ref: ConversationAttachedReference) -> some View {
- HStack(spacing: 0) {
- makeContextFileNameView(url: ref.url, isCurrentEditor: false, isDirectory: ref.isDirectory)
-
- Button(action: { chat.send(.removeReference(ref)) }) {
- Image(systemName: "xmark")
- .resizable()
- .frame(width: 8, height: 8)
- .foregroundColor(.primary.opacity(0.85))
- .padding(4)
- }
- .buttonStyle(HoverButtonStyle())
- }
- .chatContextReferenceStyle(isCurrentEditor: false, r: r)
- }
-
- @ViewBuilder
- func makeContextFileNameView(
- url: URL,
- isCurrentEditor: Bool,
- isDirectory: Bool = false,
- selection: LSPRange? = nil
- ) -> some View {
- drawFileIcon(url, isDirectory: isDirectory)
- .resizable()
- .scaledToFit()
- .frame(width: 16, height: 16)
- .foregroundColor(.primary.opacity(0.85))
- .padding(4)
- .opacity(isCurrentEditor && !isCurrentEditorContextEnabled ? 0.4 : 1.0)
-
- HStack(spacing: 0) {
- Text(url.lastPathComponent)
-
- Group {
- if isCurrentEditor, let selection {
- let startLine = selection.start.line
- let endLine = selection.end.line
- if startLine == endLine {
- Text(String(format: ":%d", selection.start.line + 1))
- } else {
- Text(String(format: ":%d-%d", selection.start.line + 1, selection.end.line + 1))
- }
- }
- }
- .foregroundColor(.secondary)
- }
- .lineLimit(1)
- .truncationMode(.middle)
- .foregroundColor(
- isCurrentEditor && !isCurrentEditorContextEnabled
- ? .secondary
- : .primary.opacity(0.85)
- )
- .font(.body)
- .opacity(isCurrentEditor && !isCurrentEditorContextEnabled ? 0.4 : 1.0)
- .help(url.getPathRelativeToHome())
- }
-
- func chatTemplateCompletion(text: String) async -> [ChatTemplate] {
- guard text.count >= 1 && text.first == "/" else { return [] }
-
- let prefix = String(text.dropFirst()).lowercased()
- let promptTemplates: [ChatTemplate] = await SharedChatService.shared.loadChatTemplates() ?? []
- let releaseNotesTemplate: ChatTemplate = .init(
- id: "releaseNotes",
- description: "What's New",
- shortDescription: "What's New",
- scopes: [.chatPanel, .agentPanel]
- )
-
- let templates = promptTemplates + [releaseNotesTemplate]
- let skippedTemplates = [ "feedback", "help" ]
-
- return templates.filter {
- $0.scopes.contains(chat.isAgentMode ? .agentPanel : .chatPanel) &&
- $0.id.lowercased().hasPrefix(prefix) &&
- !skippedTemplates.contains($0.id)
- }
- }
-
- func chatAgentCompletion(text: String) async -> [ChatAgent] {
- guard text.count >= 1 && text.first == "@" else { return [] }
- let prefix = text.dropFirst()
- var chatAgents = await SharedChatService.shared.loadChatAgents() ?? []
-
- if let index = chatAgents.firstIndex(where: { $0.slug == "project" }) {
- let projectAgent = chatAgents[index]
- chatAgents[index] = .init(slug: "workspace", name: "workspace", description: "Ask about your workspace", avatarUrl: projectAgent.avatarUrl)
- }
-
- /// only enable the @workspace
- let includedAgents = ["workspace"]
-
- return chatAgents.filter { $0.slug.hasPrefix(prefix) && includedAgents.contains($0.slug) }
- }
-
- func subscribeToActiveDocumentChangeEvent() {
- var task: Task?
- var currentFocusedEditor: SourceEditor?
-
- Publishers.CombineLatest3(
- XcodeInspector.shared.$latestActiveXcode,
- XcodeInspector.shared.$activeDocumentURL
- .removeDuplicates(),
- XcodeInspector.shared.$focusedEditor
- .removeDuplicates()
- )
- .receive(on: DispatchQueue.main)
- .sink { newXcode, newDocURL, newFocusedEditor in
- var currentEditor: ConversationFileReference?
-
- // First check for realtimeWorkspaceURL if activeWorkspaceURL is nil
- if let realtimeURL = newXcode?.realtimeDocumentURL, newDocURL == nil {
- if supportedFileExtensions.contains(realtimeURL.pathExtension) {
- currentEditor = ConversationFileReference(url: realtimeURL, isCurrentEditor: true)
- }
- } else if let docURL = newDocURL, supportedFileExtensions.contains(newDocURL?.pathExtension ?? "") {
- currentEditor = ConversationFileReference(url: docURL, isCurrentEditor: true)
- }
-
- if var currentEditor = currentEditor {
- if let selection = newFocusedEditor?.getContent().selections.first,
- selection.start != selection.end {
- currentEditor.selection = .init(start: selection.start, end: selection.end)
- }
-
- chat.send(.setCurrentEditor(currentEditor))
- }
-
- if currentFocusedEditor != newFocusedEditor {
- task?.cancel()
- task = nil
- currentFocusedEditor = newFocusedEditor
-
- if let editor = currentFocusedEditor {
- task = Task { @MainActor in
- for await _ in await editor.axNotifications.notifications()
- .filter({ $0.kind == .selectedTextChanged }) {
- handleSourceEditorSelectionChanged(editor)
- }
- }
- }
- }
- }
- .store(in: &cancellable)
- }
-
- private func handleSourceEditorSelectionChanged(_ sourceEditor: SourceEditor) {
- guard let fileURL = sourceEditor.realtimeDocumentURL,
- let currentEditorURL = chat.currentEditor?.url,
- fileURL == currentEditorURL
- else {
- return
- }
-
- var currentEditor: ConversationFileReference = .init(url: fileURL, isCurrentEditor: true)
-
- if let selection = sourceEditor.getContent().selections.first,
- selection.start != selection.end {
- currentEditor.selection = .init(start: selection.start, end: selection.end)
- }
-
- chat.send(.setCurrentEditor(currentEditor))
- }
-
- func submitChatMessage() {
- chat.send(.sendButtonTapped(UUID().uuidString))
- }
- }
-}
-
extension URL {
func getPathRelativeToHome() -> String {
let filePath = self.path
@@ -1115,7 +658,8 @@ struct ChatPanel_Preview: PreviewProvider {
id: "1",
role: .user,
text: "**Hello**",
- references: []
+ references: [],
+ requestType: .conversation
),
.init(
id: "2",
@@ -1133,25 +677,29 @@ struct ChatPanel_Preview: PreviewProvider {
kind: .class,
referenceType: .file
),
- ]
+ ],
+ requestType: .conversation
),
.init(
id: "7",
role: .ignored,
text: "Ignored",
- references: []
+ references: [],
+ requestType: .conversation
),
.init(
id: "5",
role: .assistant,
text: "Yooo",
- references: []
+ references: [],
+ requestType: .conversation
),
.init(
id: "4",
role: .user,
text: "Yeeeehh",
- references: []
+ references: [],
+ requestType: .conversation
),
.init(
id: "3",
@@ -1171,7 +719,8 @@ struct ChatPanel_Preview: PreviewProvider {
```
"""#,
references: [],
- followUp: .init(message: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce turpis dolor, malesuada quis fringilla sit amet, placerat at nunc. Suspendisse orci tortor, tempor nec blandit a, malesuada vel tellus. Nunc sed leo ligula. Ut at ligula eget turpis pharetra tristique. Integer luctus leo non elit rhoncus fermentum.", id: "3", type: "type")
+ followUp: .init(message: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce turpis dolor, malesuada quis fringilla sit amet, placerat at nunc. Suspendisse orci tortor, tempor nec blandit a, malesuada vel tellus. Nunc sed leo ligula. Ut at ligula eget turpis pharetra tristique. Integer luctus leo non elit rhoncus fermentum.", id: "3", type: "type"),
+ requestType: .conversation
),
]
@@ -1216,8 +765,8 @@ struct ChatPanel_InputMultilineText_Preview: PreviewProvider {
ChatPanel(
chat: .init(
initialState: .init(
- chatContext: .init(
- typedMessage: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce turpis dolor, malesuada quis fringilla sit amet, placerat at nunc. Suspendisse orci tortor, tempor nec blandit a, malesuada vel tellus. Nunc sed leo ligula. Ut at ligula eget turpis pharetra tristique. Integer luctus leo non elit rhoncus fermentum."),
+ editorModeContexts: [Chat.EditorMode.input: ChatContext(
+ typedMessage: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce turpis dolor, malesuada quis fringilla sit amet, placerat at nunc. Suspendisse orci tortor, tempor nec blandit a, malesuada vel tellus. Nunc sed leo ligula. Ut at ligula eget turpis pharetra tristique. Integer luctus leo non elit rhoncus fermentum.")],
history: ChatPanel_Preview.history,
isReceivingMessage: false
),
diff --git a/Core/Sources/ConversationTab/CodeBlockHighlighter.swift b/Core/Sources/ConversationTab/CodeBlockHighlighter.swift
index 553f5976..3cecf903 100644
--- a/Core/Sources/ConversationTab/CodeBlockHighlighter.swift
+++ b/Core/Sources/ConversationTab/CodeBlockHighlighter.swift
@@ -86,13 +86,13 @@ struct AsyncCodeBlockView: View {
Group {
if let highlighted = storage.highlighted {
Text(highlighted)
- .frame(maxWidth: .infinity, alignment: .leading)
} else {
Text(content).font(.init(font))
- .frame(maxWidth: .infinity, alignment: .leading)
}
}
- .frame(maxWidth: .infinity)
+ .lineLimit(nil)
+ .fixedSize(horizontal: false, vertical: true)
+ .frame(maxWidth: .infinity, alignment: .leading)
.onAppear {
storage.highlight(debounce: false, for: self)
}
diff --git a/Core/Sources/ConversationTab/Controller/DiffViewWindowController.swift b/Core/Sources/ConversationTab/Controller/DiffViewWindowController.swift
index e4da4784..4a52af45 100644
--- a/Core/Sources/ConversationTab/Controller/DiffViewWindowController.swift
+++ b/Core/Sources/ConversationTab/Controller/DiffViewWindowController.swift
@@ -2,6 +2,7 @@ import SwiftUI
import ChatService
import ComposableArchitecture
import WebKit
+import ChatAPIService
enum Style {
/// default diff view frame. Same as the `ChatPanel`
diff --git a/Core/Sources/ConversationTab/DiffViews/DiffView.swift b/Core/Sources/ConversationTab/DiffViews/DiffView.swift
index c857528e..ee66ec8b 100644
--- a/Core/Sources/ConversationTab/DiffViews/DiffView.swift
+++ b/Core/Sources/ConversationTab/DiffViews/DiffView.swift
@@ -5,6 +5,7 @@ import Logger
import ConversationServiceProvider
import ChatService
import ChatTab
+import ChatAPIService
extension FileEdit {
var originalContentByStatus: String {
diff --git a/Core/Sources/ConversationTab/DiffViews/DiffWebView.swift b/Core/Sources/ConversationTab/DiffViews/DiffWebView.swift
index cc42af5d..36c952a5 100644
--- a/Core/Sources/ConversationTab/DiffViews/DiffWebView.swift
+++ b/Core/Sources/ConversationTab/DiffViews/DiffWebView.swift
@@ -3,6 +3,7 @@ import ChatService
import SwiftUI
import WebKit
import Logger
+import ChatAPIService
struct DiffWebView: NSViewRepresentable {
@Perception.Bindable var chat: StoreOf
diff --git a/Core/Sources/ConversationTab/Features/ConversationCodeReviewFeature.swift b/Core/Sources/ConversationTab/Features/ConversationCodeReviewFeature.swift
index c85ca212..d55acc6d 100644
--- a/Core/Sources/ConversationTab/Features/ConversationCodeReviewFeature.swift
+++ b/Core/Sources/ConversationTab/Features/ConversationCodeReviewFeature.swift
@@ -70,9 +70,11 @@ public struct ConversationCodeReviewFeature {
"/bin/bash",
arguments: [
"-c",
- "xed -l \(lineNumber+1) \"\(fileURL.path)\""
+ "xed -l \(lineNumber+1) \"${TARGET_REVIEW_FILE}\""
],
- environment: [:]
+ environment: [
+ "TARGET_REVIEW_FILE": fileURL.path
+ ]
)
} catch {
print(error)
diff --git a/Core/Sources/ConversationTab/FilePicker.swift b/Core/Sources/ConversationTab/FilePicker.swift
index 2b3fba9d..284c52ec 100644
--- a/Core/Sources/ConversationTab/FilePicker.swift
+++ b/Core/Sources/ConversationTab/FilePicker.swift
@@ -13,6 +13,7 @@ public struct FilePicker: View {
@State private var searchText = ""
@State private var selectedId: Int = 0
@State private var localMonitor: Any? = nil
+ @AppStorage(\.chatFontSize) var chatFontSize
// Only showup direct sub directories
private var defaultReferencesForDisplay: [ConversationAttachedReference]? {
@@ -76,6 +77,7 @@ public struct FilePicker: View {
.foregroundColor(.secondary)
TextField("Search files...", text: $searchText)
+ .scaledFont(.body)
.textFieldStyle(PlainTextFieldStyle())
.foregroundColor(searchText.isEmpty ? Color(nsColor: .placeholderTextColor) : Color(nsColor: .textColor))
.focused($isSearchBarFocused)
@@ -92,6 +94,7 @@ public struct FilePicker: View {
}
}) {
Image(systemName: "xmark.circle.fill")
+ .scaledFont(.body)
.foregroundColor(.secondary)
}
.buttonStyle(HoverButtonStyle())
@@ -173,7 +176,6 @@ public struct FilePicker: View {
RoundedRectangle(cornerRadius: 8)
.stroke(Color(nsColor: .separatorColor), lineWidth: 1)
)
- .padding(.horizontal, 12)
}
}
@@ -201,24 +203,23 @@ struct FileRowView: View {
var body: some View {
WithPerceptionTracking {
- HStack {
+ HStack(alignment: .center) {
drawFileIcon(ref.url, isDirectory: ref.isDirectory)
- .resizable()
.scaledToFit()
- .frame(width: 16, height: 16)
+ .scaledFrame(width: 16, height: 16)
.hoverSecondaryForeground(isHovered: selectedId == id)
.padding(.leading, 4)
HStack(spacing: 4) {
Text(ref.displayName)
- .font(.body)
+ .scaledFont(.body)
.hoverPrimaryForeground(isHovered: selectedId == id)
.lineLimit(1)
.truncationMode(.middle)
.layoutPriority(1)
Text(ref.relativePath)
- .font(.caption)
+ .scaledFont(.caption)
.hoverSecondaryForeground(isHovered: selectedId == id)
.lineLimit(1)
.truncationMode(.middle)
diff --git a/Core/Sources/ConversationTab/ModelPicker/ModelPicker.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModeAndModelPickerPicker.swift
similarity index 57%
rename from Core/Sources/ConversationTab/ModelPicker/ModelPicker.swift
rename to Core/Sources/ConversationTab/ModeAndModelPicker/ModeAndModelPickerPicker.swift
index 396f9a26..e8302f4e 100644
--- a/Core/Sources/ConversationTab/ModelPicker/ModelPicker.swift
+++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModeAndModelPickerPicker.swift
@@ -8,45 +8,41 @@ 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()
@State private var agentScopeCache: ScopeCache = ScopeCache()
@State var isMCPFFEnabled: Bool
+ @State var isBYOKFFEnabled: Bool
@State private var cancellables = Set()
- let minimumPadding: Int = 48
- let attributes: [NSAttributedString.Key: NSFont] = [.font: NSFont.systemFont(ofSize: NSFont.systemFontSize)]
-
- var spaceWidth: CGFloat {
- "\u{200A}".size(withAttributes: attributes).width
- }
+ let attributes: [NSAttributedString.Key: NSFont] = ModelMenuItemFormatter.attributes
- var minimumPaddingWidth: CGFloat {
- spaceWidth * CGFloat(minimumPadding)
- }
-
- init() {
+ init(projectRootURL: URL?, selectedAgent: Binding) {
+ self.projectRootURL = projectRootURL
+ self._selectedAgent = selectedAgent
let initialModel = AppState.shared.getSelectedModel() ??
CopilotModelManager.getDefaultChatModel()
self._selectedModel = State(initialValue: initialModel)
self.isMCPFFEnabled = FeatureFlagNotifierImpl.shared.featureFlags.mcp
+ self.isBYOKFFEnabled = FeatureFlagNotifierImpl.shared.featureFlags.byok
updateAgentPicker()
}
private func subscribeToFeatureFlagsDidChangeEvent() {
FeatureFlagNotifierImpl.shared.featureFlagsDidChange.sink(receiveValue: { featureFlags in
isMCPFFEnabled = featureFlags.mcp
+ isBYOKFFEnabled = featureFlags.byok
})
.store(in: &cancellables)
}
@@ -70,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 {
@@ -107,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)
}
@@ -136,14 +111,18 @@ struct ModelPicker: View {
func updateCurrentModel() {
let currentModel = AppState.shared.getSelectedModel()
- let allAvailableModels = copilotModels + byokModels
+ var allAvailableModels = copilotModels
+ if isBYOKFFEnabled {
+ 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)
@@ -156,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
}
}
@@ -164,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)
@@ -184,76 +180,53 @@ struct ModelPicker: View {
updateModelCacheIfNeeded(for: scope)
}
- // Model picker menu component
- private var modelPickerMenu: some View {
- Menu(selectedModel?.displayName ?? selectedModel?.modelName ?? "") {
- // Group models by premium status
- let premiumModels = copilotModels.filter {
- $0.billing?.isPremium == true
- }
- let standardModels = copilotModels.filter {
- $0.billing?.isPremium == false || $0.billing == nil
- }
-
- // Display standard models section if available
- modelSection(title: "Standard Models", models: standardModels)
+ // Parse model string in format "{Model DisplayName} ({providerName or copilot})"
+ // If no parentheses, defaults to Copilot model
+ private func parseModelString(_ modelString: String, from availableModels: [LLMModel]) -> LLMModel? {
+ var displayName: String
+ var isCopilotModel: Bool
+ var provider: String = ""
+
+ // Extract display name and provider from the format: "DisplayName (provider)"
+ if let openParenIndex = modelString.lastIndex(of: "("),
+ let closeParenIndex = modelString.lastIndex(of: ")"),
+ openParenIndex < closeParenIndex {
- // Display premium models section if available
- modelSection(title: "Premium Models", models: premiumModels)
+ let displayNameEndIndex = modelString.index(before: openParenIndex)
+ displayName = String(modelString[.. some View {
- if !models.isEmpty {
- Section(title) {
- ForEach(models, id: \.self) { model in
- modelButton(for: model)
- }
+
+ // Search in available models
+ return availableModels.first { model in
+ let matchesDisplayName = (model.displayName ?? model.modelName) == displayName
+
+ if isCopilotModel {
+ // For Copilot models, providerName should be nil or empty
+ return matchesDisplayName && (model.providerName == nil || model.providerName?.isEmpty == true)
+ } else {
+ // For BYOK models, providerName should match (case-insensitive)
+ guard let modelProvider = model.providerName else { return false }
+ return matchesDisplayName && modelProvider.lowercased() == provider.lowercased()
}
}
}
- // Helper function to create a model selection button
- private func modelButton(for model: LLMModel) -> some View {
- Button {
- AppState.shared.setSelectedModel(model)
- } label: {
- Text(createModelMenuItemAttributedString(
- modelName: model.displayName ?? model.modelName,
- isSelected: selectedModel == model,
- cachedMultiplierText: currentCache.modelMultiplierCache[model.modelName.appending(model.providerName ?? "")] ?? ""
- ))
- }
- }
-
private var mcpButton: some View {
Group {
if isMCPFFEnabled {
Button(action: {
- try? launchHostAppMCPSettings()
+ let currentSubMode = AppState.shared.getSelectedAgentSubMode()
+ try? launchHostAppToolsSettings(currentAgentSubMode: currentSubMode)
}) {
mcpIcon.foregroundColor(.primary.opacity(0.85))
}
@@ -273,7 +246,7 @@ struct ModelPicker: View {
Image(systemName: "wrench.and.screwdriver")
.resizable()
.scaledToFit()
- .frame(width: 16, height: 16)
+ .scaledFrame(width: 16, height: 16)
.padding(4)
.font(Font.system(size: 11, weight: .semibold))
}
@@ -283,10 +256,19 @@ struct ModelPicker: View {
WithPerceptionTracking {
HStack(spacing: 0) {
// Custom segmented control with color change
- ChatModePicker(chatMode: $chatMode, onScopeChange: switchModelsForScope)
- .onAppear() {
+ ChatModePicker(
+ projectRootURL: projectRootURL,
+ chatMode: $chatMode,
+ selectedAgent: $selectedAgent,
+ onScopeChange: switchModelsForScope
+ )
+ .onAppear {
updateAgentPicker()
}
+ .onReceive(
+ NotificationCenter.default.publisher(for: .gitHubCopilotChatModeDidChange)) { _ in
+ updateAgentPicker()
+ }
if chatMode == "Agent" {
mcpButton
@@ -295,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()
}
@@ -332,6 +320,9 @@ struct ModelPicker: View {
.onChange(of: chatMode) { _ in
updateCurrentModel()
}
+ .onChange(of: isBYOKFFEnabled) { _ in
+ updateCurrentModel()
+ }
.onReceive(NotificationCenter.default.publisher(for: .gitHubCopilotSelectedModelDidChange)) { _ in
updateCurrentModel()
}
@@ -341,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 + 20)
- }
-
@MainActor
func refreshModels() async {
let now = Date()
@@ -364,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 83%
rename from Core/Sources/ConversationTab/ModelPicker/ModeButton.swift
rename to Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/ModeButton.swift
index b204e04c..7964d448 100644
--- a/Core/Sources/ConversationTab/ModelPicker/ModeButton.swift
+++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/ModeButton.swift
@@ -1,4 +1,5 @@
import SwiftUI
+import SharedUIComponents
public struct ModeButton: View {
let title: String
@@ -11,12 +12,13 @@ public struct ModeButton: View {
public var body: some View {
Button(action: action) {
Text(title)
- .padding(.horizontal, 6)
- .padding(.vertical, 0)
+ .scaledFont(size: 12)
+ .scaledPadding(.horizontal, 6)
+ .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..a4542c90
--- /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 ?? false
+ )
+ }
+}
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 94cd8051..00000000
--- a/Core/Sources/ConversationTab/ModelPicker/ChatModePicker.swift
+++ /dev/null
@@ -1,95 +0,0 @@
-import SwiftUI
-import Persist
-import ConversationServiceProvider
-import GitHubCopilotService
-import Combine
-
-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)
- }
- )
- }
- .padding(1)
- .frame(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/Styles.swift b/Core/Sources/ConversationTab/Styles.swift
index a0366ccd..eca980d5 100644
--- a/Core/Sources/ConversationTab/Styles.swift
+++ b/Core/Sources/ConversationTab/Styles.swift
@@ -36,6 +36,7 @@ extension NSAppearance {
extension View {
var messageBubbleCornerRadius: Double { 8 }
var hoverableImageCornerRadius: Double { 4 }
+ var inputAreaTextEditorCornerRadius: Double { 12 }
func codeBlockLabelStyle() -> some View {
relativeLineSpacing(.em(0.225))
@@ -183,10 +184,6 @@ struct RoundedCorners: Shape {
// Chat Message Styles
extension View {
- func chatMessageHeaderTextStyle() -> some View {
- // semibold -> 600
- font(.system(size: 13, weight: .semibold))
- }
func chatContextReferenceStyle(isCurrentEditor: Bool, r: Double) -> some View {
background(
@@ -204,7 +201,7 @@ extension View {
struct CodeReviewCardBackground: View {
var body: some View {
- RoundedRectangle(cornerRadius: 4)
+ RoundedRectangle(cornerRadius: 12)
.stroke(.black.opacity(0.17), lineWidth: 1)
.background(Color.gray.opacity(0.05))
}
@@ -212,7 +209,7 @@ struct CodeReviewCardBackground: View {
struct CodeReviewHeaderBackground: View {
var body: some View {
- RoundedRectangle(cornerRadius: 4)
+ RoundedRectangle(cornerRadius: 12)
.stroke(.black.opacity(0.17), lineWidth: 1)
.background(Color.gray.opacity(0.1))
}
diff --git a/Core/Sources/ConversationTab/TerminalViews/RunInTerminalToolView.swift b/Core/Sources/ConversationTab/TerminalViews/RunInTerminalToolView.swift
index c75e864e..7f6725a5 100644
--- a/Core/Sources/ConversationTab/TerminalViews/RunInTerminalToolView.swift
+++ b/Core/Sources/ConversationTab/TerminalViews/RunInTerminalToolView.swift
@@ -1,8 +1,11 @@
-import SwiftUI
-import XcodeInspector
-import ConversationServiceProvider
+import ChatService
import ComposableArchitecture
+import ConversationServiceProvider
+import GitHubCopilotService
+import SharedUIComponents
+import SwiftUI
import Terminal
+import XcodeInspector
struct RunInTerminalToolView: View {
let tool: AgentToolCall
@@ -17,13 +20,15 @@ struct RunInTerminalToolView: View {
@AppStorage(\.codeBackgroundColorDark) var codeBackgroundColorDark
@AppStorage(\.codeForegroundColorDark) var codeForegroundColorDark
@AppStorage(\.chatFontSize) var chatFontSize
- @AppStorage(\.chatCodeFont) var chatCodeFont
@Environment(\.colorScheme) var colorScheme
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
@@ -71,11 +76,10 @@ struct RunInTerminalToolView: View {
Image("Terminal")
.resizable()
.scaledToFit()
- .frame(width: 16, height: 16)
+ .scaledFrame(width: 16, height: 16)
Text(self.title)
- .font(.system(size: chatFontSize))
- .fontWeight(.semibold)
+ .scaledFont(size: chatFontSize, weight: .semibold)
.foregroundStyle(.primary)
.background(Color.clear)
.frame(maxWidth: .infinity, alignment: .leading)
@@ -83,7 +87,7 @@ struct RunInTerminalToolView: View {
toolView
}
- .padding(8)
+ .scaledPadding(8)
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
@@ -119,13 +123,15 @@ struct RunInTerminalToolView: View {
if command != nil {
HStack(spacing: 4) {
statusIcon
- .frame(width: 16, height: 16)
+ .scaledFrame(width: 16, height: 16)
Text(command!)
+ .lineLimit(nil)
.textSelection(.enabled)
- .font(.system(size: chatFontSize, design: .monospaced))
- .padding(8)
+ .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))
@@ -142,25 +148,212 @@ struct RunInTerminalToolView: View {
terminalSession: terminalSession,
onTerminalInput: terminalSession.handleTerminalInput
)
- .frame(minHeight: 200, maxHeight: 400)
+ .scaledFrame(minHeight: 200, maxHeight: 400)
} else if tool.status == .waitForConfirmation {
ThemedMarkdownText(text: explanation ?? "", chat: chat)
.frame(maxWidth: .infinity, alignment: .leading)
HStack {
- Button("Cancel") {
+ Button(action: {
chat.send(.toolCallCancelled(tool.id))
+ }) {
+ Text("Skip")
+ .scaledFont(.body)
}
- Button("Continue") {
- chat.send(.toolCallAccepted(tool.id))
+ 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)
- .padding(.top, 4)
+ .scaledPadding(.top, 4)
+ }
+ }
+ }
+ }
+
+ private func terminalMenuItems(command: String) -> [SplitButtonMenuItem] {
+ var items: [SplitButtonMenuItem] = []
+
+ let subCommands = ToolAutoApprovalManager.extractSubCommandsWithTreeSitter(command)
+ let commandNames = extractCommandNamesForMenu(subCommands)
+ let commandNamesLabel = formatCommandNameListForMenu(commandNames)
+
+ let trimmedCommand = command.trimmingCharacters(in: .whitespacesAndNewlines)
+ let trimmedSubCommands = subCommands
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+ .filter { !$0.isEmpty }
+
+ let shouldShowExactCommandLineItems = !(
+ trimmedSubCommands.count == 1 &&
+ trimmedSubCommands[0] == trimmedCommand &&
+ commandNames.contains(trimmedCommand)
+ )
+
+ let conversationId = tool.invokeParams?.conversationId ?? ""
+ let hasConversationId = !conversationId.isEmpty
+
+ // Session-scoped
+ if hasConversationId, !commandNames.isEmpty {
+ items.append(
+ SplitButtonMenuItem(title: sessionAllowCommandsTitle(commandNamesLabel: commandNamesLabel, commandCount: commandNames.count)) {
+ chat.send(
+ .toolCallAcceptedWithApproval(
+ tool.id,
+ .terminal(
+ scope: .session(conversationId),
+ commands: commandNames
+ )
+ )
+ )
+ }
+ )
+ }
+
+ // Global
+ if !commandNames.isEmpty {
+ items.append(
+ SplitButtonMenuItem(title: alwaysAllowCommandsTitle(commandNamesLabel: commandNamesLabel, commandCount: commandNames.count)) {
+ chat.send(
+ .toolCallAcceptedWithApproval(
+ tool.id,
+ .terminal(
+ scope: .global,
+ commands: commandNames
+ )
+ )
+ )
+ }
+ )
+ }
+
+ items.append(.divider())
+
+ if shouldShowExactCommandLineItems {
+ // Session-scoped exact command line
+ if hasConversationId {
+ items.append(
+ SplitButtonMenuItem(title: "Allow Exact Command Line in this Session") {
+ chat.send(
+ .toolCallAcceptedWithApproval(
+ tool.id,
+ .terminal(
+ scope: .session(conversationId),
+ commands: [command]
+ )
+ )
+ )
+ }
+ )
+ }
+
+ // Global exact command line
+ items.append(
+ SplitButtonMenuItem(title: "Always Allow Exact Command Line") {
+ chat.send(
+ .toolCallAcceptedWithApproval(
+ tool.id,
+ .terminal(
+ scope: .global,
+ commands: [command]
+ )
+ )
+ )
+ }
+ )
+
+ items.append(.divider())
+ }
+
+ // Session-scoped allow all
+ if hasConversationId {
+ items.append(
+ SplitButtonMenuItem(title: "Allow All Commands in this Session") {
+ chat.send(
+ .toolCallAcceptedWithApproval(
+ tool.id,
+ .terminal(
+ scope: .session(conversationId),
+ commands: []
+ )
+ )
+ )
}
+ )
+ }
+
+ items.append(.divider())
+ items.append(
+ SplitButtonMenuItem(title: "Configure Auto Approve...") {
+ chat.send(.openAutoApproveSettings)
+ }
+ )
+
+ return items
+ }
+
+ private func formatSubCommandListForMenu(_ subCommands: [String]) -> String {
+ let trimmed = subCommands.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty }
+ guard !trimmed.isEmpty else { return "(none)" }
+ return trimmed.joined(separator: ", ")
+ }
+
+ private func extractCommandNamesForMenu(_ subCommands: [String]) -> [String] {
+ var result: [String] = []
+ var seen: Set = []
+
+ for subCommand in subCommands {
+ guard let name = ToolAutoApprovalManager.extractTerminalCommandName(fromSubCommand: subCommand) else {
+ continue
}
+ let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { continue }
+ guard !seen.contains(trimmed) else { continue }
+ seen.insert(trimmed)
+ result.append(trimmed)
+ }
+
+ return result
+ }
+
+ private func formatCommandNameListForMenu(_ commandNames: [String]) -> String {
+ let trimmed = commandNames.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty }
+ guard !trimmed.isEmpty else { return "(none)" }
+
+ func suffixEllipsis(_ name: String) -> String { "`\(name) ...`" }
+
+ return trimmed.map(suffixEllipsis).joined(separator: ", ")
+ }
+
+ private func sessionAllowCommandsTitle(commandNamesLabel: String, commandCount: Int) -> String {
+ if commandCount == 1 {
+ return "Allow \(commandNamesLabel) in this Session"
+ }
+ return "Allow Commands \(commandNamesLabel) in this Session"
+ }
+
+ private func alwaysAllowCommandsTitle(commandNamesLabel: String, commandCount: Int) -> String {
+ if commandCount == 1 {
+ return "Always Allow \(commandNamesLabel)"
}
+ return "Always Allow Commands \(commandNamesLabel)"
}
}
diff --git a/Core/Sources/ConversationTab/ViewExtension.swift b/Core/Sources/ConversationTab/ViewExtension.swift
index 912c9687..181f3cbd 100644
--- a/Core/Sources/ConversationTab/ViewExtension.swift
+++ b/Core/Sources/ConversationTab/ViewExtension.swift
@@ -1,4 +1,5 @@
import SwiftUI
+import ComposableArchitecture
let ITEM_SELECTED_COLOR = Color("ItemSelectedColor")
@@ -82,4 +83,58 @@ extension View {
public func hoverSecondaryForeground(isHovered: Bool) -> some View {
self.hoverForeground(isHovered: isHovered, defaultColor: .secondary)
}
+
+ // MARK: - Editor Mode
+
+ /// Dims the view when in edit mode and provides tap/keyboard exit functionality
+ /// - Parameters:
+ /// - chat: The chat store
+ /// - messageId: Optional message ID to determine if this specific message should be dimmed
+ /// - isDimmed: Whether this view should be dimmed (defaults to true when editing affects this view)
+ /// - allowTapToExit: Whether tapping on this view should exit edit mode (defaults to true)
+ func dimWithExitEditMode(
+ _ chat: StoreOf,
+ applyTo messageId: String? = nil,
+ isDimmed: Bool? = nil,
+ allowTapToExit: Bool = true
+ ) -> some View {
+ let editUserMessageEffectedMessageIds = chat.editUserMessageEffectedMessages.map { $0.id }
+ let shouldDim = isDimmed ?? {
+ guard chat.editorMode.isEditingUserMessage else { return false }
+ guard let messageId else { return true }
+ return editUserMessageEffectedMessageIds.contains(messageId)
+ }()
+
+ let isInEditMode = chat.editorMode.isEditingUserMessage
+ let shouldAllowTapExit = allowTapToExit && isInEditMode
+
+ return self
+ .opacity(shouldDim && isInEditMode ? 0.5 : 1)
+ .overlay(
+ Group {
+ if shouldAllowTapExit {
+ Color.clear
+ .contentShape(Rectangle()) // Ensure the entire area is tappable
+ .onTapGesture {
+ if shouldAllowTapExit {
+ chat.send(.setEditorMode(.input))
+ }
+ }
+ }
+ }
+ )
+ .background(
+ // Global escape key handler - only add once per view hierarchy
+ Group {
+ if isInEditMode {
+ Button("") {
+ chat.send(.setEditorMode(.input))
+ }
+ .keyboardShortcut(.escape, modifiers: [])
+ .opacity(0)
+ .accessibilityHidden(true)
+ }
+ }
+ )
+ }
}
diff --git a/Core/Sources/ConversationTab/Views/BotMessage.swift b/Core/Sources/ConversationTab/Views/BotMessage.swift
index 70dcd3f2..8d75db57 100644
--- a/Core/Sources/ConversationTab/Views/BotMessage.swift
+++ b/Core/Sources/ConversationTab/Views/BotMessage.swift
@@ -7,61 +7,34 @@ import SwiftUI
import ConversationServiceProvider
import ChatTab
import ChatAPIService
+import HostAppActivator
struct BotMessage: View {
var r: Double { messageBubbleCornerRadius }
- let id: String
- let text: String
- let references: [ConversationReference]
- let followUp: ConversationFollowUp?
- let errorMessages: [String]
+ let message: DisplayedChatMessage
let chat: StoreOf
- let steps: [ConversationProgressStep]
- let editAgentRounds: [AgentRound]
- let panelMessages: [CopilotShowMessageParams]
- let codeReviewRound: CodeReviewRound?
+ var id: String {
+ message.id
+ }
+ var text: String { message.text }
+ var references: [ConversationReference] { message.references }
+ var followUp: ConversationFollowUp? { message.followUp }
+ var errorMessages: [String] { message.errorMessages }
+ var steps: [ConversationProgressStep] { message.steps }
+ var thinking: [MessageThinking] { message.thinking }
+ var editAgentRounds: [AgentRound] { message.editAgentRounds }
+ var panelMessages: [CopilotShowMessageParams] { message.panelMessages }
+ var codeReviewRound: CodeReviewRound? { message.codeReviewRound }
@Environment(\.colorScheme) var colorScheme
@AppStorage(\.chatFontSize) var chatFontSize
- @State var isReferencesPresented = false
-
- struct ResponseToolBar: View {
- let id: String
- let chat: StoreOf
- let text: String
-
- var body: some View {
- HStack(spacing: 4) {
-
- UpvoteButton { rating in
- chat.send(.upvote(id, rating))
- }
-
- DownvoteButton { rating in
- chat.send(.downvote(id, rating))
- }
-
- CopyButton {
- NSPasteboard.general.clearContents()
- NSPasteboard.general.setString(text, forType: .string)
- chat.send(.copyCode(id))
- }
-
- Spacer() // Pushes the buttons to the left
- }
- }
- }
+ @State var isHovering = false
struct ReferenceButton: View {
- var r: Double { messageBubbleCornerRadius }
let references: [ConversationReference]
let chat: StoreOf
- @Binding var isReferencesPresented: Bool
-
- @State var isReferencesHovered = false
-
@AppStorage(\.chatFontSize) var chatFontSize
func MakeReferenceTitle(references: [ConversationReference]) -> String {
@@ -75,148 +48,198 @@ struct BotMessage: View {
}
var body: some View {
- VStack(alignment: .leading, spacing: 8) {
- Button(action: {
- isReferencesPresented.toggle()
- }, label: {
- HStack(spacing: 4) {
- Image(systemName: isReferencesPresented ? "chevron.down" : "chevron.right")
-
- Text(MakeReferenceTitle(references: references))
- .font(.system(size: chatFontSize))
- }
- .background {
- RoundedRectangle(cornerRadius: r - 4)
- .fill(isReferencesHovered ? Color.gray.opacity(0.1) : Color.clear)
- }
- .foregroundStyle(.secondary)
- })
- .buttonStyle(HoverButtonStyle())
- .accessibilityValue(isReferencesPresented ? "Collapse" : "Expand")
+ let files = references.map { $0.filePath }
+ let fileHelpTexts = Dictionary(uniqueKeysWithValues: references.compactMap { reference in
+ guard reference.url != nil else { return nil }
+ return (reference.filePath, reference.getPathRelativeToHome())
+ })
+ let progressMessage = Text(MakeReferenceTitle(references: references))
+ .foregroundStyle(.secondary)
+
+ HStack(spacing: 0) {
+ ExpandableFileListView(
+ progressMessage: progressMessage,
+ files: files,
+ chatFontSize: chatFontSize,
+ helpText: "View referenced files",
+ onFileClick: { filePath in
+ if let reference = references.first(where: { $0.filePath == filePath }) {
+ chat.send(.referenceClicked(reference))
+ }
+ },
+ fileHelpTexts: fileHelpTexts
+ )
- if isReferencesPresented {
- ReferenceList(references: references, chat: chat)
- .background(
- RoundedRectangle(cornerRadius: 5)
- .stroke(Color.gray, lineWidth: 0.2)
- )
- }
+ Spacer()
}
}
}
-
- private var agentWorkingStatus: some View {
- HStack(spacing: 4) {
- ProgressView()
- .controlSize(.small)
- .frame(width: 20, height: 16)
- .scaleEffect(0.7)
-
- Text("Working...")
- .font(.system(size: chatFontSize))
- .foregroundColor(.secondary)
- }
- }
var body: some View {
- HStack {
- VStack(alignment: .leading, spacing: 8) {
- CopilotMessageHeader()
-
- if !references.isEmpty {
- WithPerceptionTracking {
- ReferenceButton(
- references: references,
- chat: chat,
- isReferencesPresented: $isReferencesPresented
+ WithPerceptionTracking {
+ HStack {
+ VStack(alignment: .leading, spacing: 8) {
+ if !references.isEmpty {
+ WithPerceptionTracking {
+ ReferenceButton(
+ references: references,
+ chat: chat
+ )
+ }
+ }
+
+ // progress step
+ if steps.count > 0 {
+ ProgressStep(steps: steps)
+
+ }
+
+ 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 {
- ThemedMarkdownText(text: text, chat: chat)
- }
-
- if let codeReviewRound = codeReviewRound {
- CodeReviewMainView(
- store: chat, round: codeReviewRound
- )
- }
+ if editAgentRounds.count > 0 {
+ ProgressAgentRound(rounds: editAgentRounds, chat: chat, isStreaming: isThinkingStreaming())
+ }
+
+ if !text.isEmpty {
+ Group{
+ ThemedMarkdownText(text: text, chat: chat)
+ }
+ .scaledPadding(.leading, 2)
+ .scaledPadding(.vertical, 4)
+ }
+
+ if let codeReviewRound = codeReviewRound {
+ CodeReviewMainView(
+ store: chat, round: codeReviewRound
+ )
+ .frame(maxWidth: .infinity)
+ }
+
+ if !errorMessages.isEmpty {
+ buildErrorMessageView()
+ }
- if !errorMessages.isEmpty {
- VStack(spacing: 4) {
- ForEach(errorMessages.indices, id: \.self) { index in
- if let attributedString = try? AttributedString(markdown: errorMessages[index]) {
- NotificationBanner(style: .warning) {
- Text(attributedString)
+ HStack {
+ if shouldShowTurnStatus() {
+ TurnStatusView(
+ message: message,
+ 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)
}
}
-
- if shouldShowWorkingStatus() {
- agentWorkingStatus
+ .padding(.leading, message.parentTurnId != nil ? 4 : 0)
+ .shadow(color: .black.opacity(0.05), radius: 6)
+ .contextMenu {
+ Button("Copy") {
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(text, forType: .string)
+ }
+ .scaledFont(.body)
+
+ Button("Set as Extra System Prompt") {
+ chat.send(.setAsExtraPromptButtonTapped(id))
+ }
+ .scaledFont(.body)
+
+ Divider()
+
+ Button("Delete") {
+ chat.send(.deleteMessageButtonTapped(id))
+ }
+ .scaledFont(.body)
}
-
- if shouldShowToolBar() {
- ResponseToolBar(id: id, chat: chat, text: text)
+ .onHover {
+ isHovering = $0
}
}
- .shadow(color: .black.opacity(0.05), radius: 6)
- .contextMenu {
- Button("Copy") {
- NSPasteboard.general.clearContents()
- NSPasteboard.general.setString(text, forType: .string)
- }
-
- Button("Set as Extra System Prompt") {
- chat.send(.setAsExtraPromptButtonTapped(id))
- }
-
- Divider()
-
- Button("Delete") {
- chat.send(.deleteMessageButtonTapped(id))
+ }
+ }
+
+ @ViewBuilder
+ private func buildErrorMessageView() -> some View {
+ VStack(spacing: 4) {
+ ForEach(errorMessages.indices, id: \.self) { index in
+ if let attributedString = try? AttributedString(markdown: errorMessages[index]) {
+ NotificationBanner(style: .warning) {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(attributedString)
+
+ if isSettingsActionableError(errorMessages[index]) {
+ Button(action: {
+ Task {
+ try? launchHostAppAdvancedSettings()
+ }
+ }) {
+ Text("Open Settings")
+ }
+ .buttonStyle(.link)
+ }
+ }
+ }
}
}
}
+ .scaledPadding(.vertical, 4)
}
- private func shouldShowWorkingStatus() -> Bool {
- let hasRunningStep: Bool = steps.contains(where: { $0.status == .running })
- let hasRunningRound: Bool = editAgentRounds.contains(where: { round in
- return round.toolCalls?.contains(where: { $0.status == .running }) ?? false
- })
-
- if hasRunningStep || hasRunningRound {
+ private func isSettingsActionableError(_ message: String) -> Bool {
+ message == HardCodedToolRoundExceedErrorMessage ||
+ message == SSLCertificateErrorMessage
+ }
+
+ private func shouldShowTurnStatus() -> Bool {
+ guard isLatestAssistantMessage() else {
return false
}
- // Only show working status for the current bot message being received
- return chat.isReceivingMessage && isLatestAssistantMessage()
+ if steps.isEmpty && editAgentRounds.isEmpty {
+ return true
+ }
+
+ if !steps.isEmpty {
+ return !message.text.isEmpty
+ }
+
+ return true
}
private func shouldShowToolBar() -> Bool {
// Always show toolbar for historical messages
- if !isLatestAssistantMessage() { return true }
+ if !isLatestAssistantMessage() { return isHovering }
// For current message, only show toolbar when message is complete
return !chat.isReceivingMessage
@@ -226,76 +249,97 @@ struct BotMessage: View {
let lastMessage = chat.history.last
return lastMessage?.role == .assistant && lastMessage?.id == id
}
+
+ private func isThinkingStreaming() -> Bool {
+ guard isLatestAssistantMessage(), chat.isReceivingMessage else { return false }
+ switch message.turnStatus {
+ case .success, .error, .cancelled: return false
+ default: return true
+ }
+ }
}
-struct ReferenceList: View {
- let references: [ConversationReference]
- let chat: StoreOf
+private struct TurnStatusView: View {
+
+ let message: DisplayedChatMessage
+ let isSummarizingConversation: Bool
- 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.. some View {
+ HStack(spacing: 4) {
+ Image(systemName: icon)
+ .scaledFont(size: chatFontSize)
+ .foregroundColor(iconColor)
+ .conditionalFontWeight(.medium)
+
+ Text(text)
+ .scaledFont(size: chatFontSize - 1)
+ .foregroundColor(.secondary)
+ }
}
}
@@ -328,72 +372,34 @@ struct BotMessage_Previews: PreviewProvider {
static var previews: some View {
let chatTabInfo = ChatTabInfo(id: "id", workspacePath: "path", username: "name")
BotMessage(
- id: "1",
- text: """
- **Hey**! What can I do for you?**Hey**! What can I do for you?**Hey**! What can I do for you?**Hey**! What can I do for you?
- ```swift
- func foo() {}
- ```
- """,
- references: .init(repeating: .init(
- uri: "/Core/Sources/ConversationTab/Views/BotMessage.swift",
- status: .included,
- kind: .class,
- referenceType: .file
- ), count: 2),
- followUp: ConversationFollowUp(message: "followup question", id: "id", type: "type"),
- errorMessages: ["Sorry, an error occurred while generating a response."],
+ message: .init(
+ id: "1",
+ role: .assistant,
+ text: """
+ **Hey**! What can I do for you?**Hey**! What can I do for you?**Hey**! What can I do for you?**Hey**! What can I do for you?
+ ```swift
+ func foo() {}
+ ```
+ """,
+ references: .init(
+ repeating: .init(
+ uri: "/Core/Sources/ConversationTab/Views/BotMessage.swift",
+ status: .included,
+ kind: .class,
+ referenceType: .file),
+ count: 2
+ ),
+ followUp: ConversationFollowUp(message: "followup question", id: "id", type: "type"),
+ errorMessages: ["Sorry, an error occurred while generating a response."],
+ steps: steps,
+ editAgentRounds: agentRounds,
+ panelMessages: [],
+ codeReviewRound: nil,
+ requestType: .conversation
+ ),
chat: .init(initialState: .init(), reducer: { Chat(service: ChatService.service(for: chatTabInfo)) }),
- steps: steps,
- editAgentRounds: agentRounds,
- panelMessages: [],
- codeReviewRound: nil
)
.padding()
.fixedSize(horizontal: true, vertical: true)
}
}
-
-struct ReferenceList_Previews: PreviewProvider {
- static var previews: some View {
- let chatTabInfo = ChatTabInfo(id: "id", workspacePath: "path", username: "name")
- ReferenceList(references: [
- .init(
- uri: "/Core/Sources/ConversationTab/Views/BotMessage.swift",
- status: .included,
- kind: .class,
- referenceType: .file
- ),
- .init(
- uri: "/Core/Sources/ConversationTab/Views",
- status: .included,
- kind: .struct,
- referenceType: .file
- ),
- .init(
- uri: "/Core/Sources/ConversationTab/Views/BotMessage.swift",
- status: .included,
- kind: .function,
- referenceType: .file
- ),
- .init(
- uri: "/Core/Sources/ConversationTab/Views/BotMessage.swift",
- status: .included,
- kind: .case,
- referenceType: .file
- ),
- .init(
- uri: "/Core/Sources/ConversationTab/Views/BotMessage.swift",
- status: .included,
- kind: .extension,
- referenceType: .file
- ),
- .init(
- uri: "/Core/Sources/ConversationTab/Views/BotMessage.swift",
- status: .included,
- kind: .webpage,
- referenceType: .file
- ),
- ], chat: .init(initialState: .init(), reducer: { Chat(service: ChatService.service(for: chatTabInfo)) }))
- }
-}
diff --git a/Core/Sources/ConversationTab/Views/BotMessage/ResponseToolBar.swift b/Core/Sources/ConversationTab/Views/BotMessage/ResponseToolBar.swift
new file mode 100644
index 00000000..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
new file mode 100644
index 00000000..346981ef
--- /dev/null
+++ b/Core/Sources/ConversationTab/Views/ChatPanelInputArea/ChatPanelInputArea.swift
@@ -0,0 +1,34 @@
+import SwiftUI
+import ComposableArchitecture
+
+struct ChatPanelInputArea: View {
+ let chat: StoreOf
+ let r: Double
+ let editorMode: Chat.EditorMode
+ @FocusState var focusedField: Chat.State.Field?
+
+ var body: some View {
+ HStack {
+ InputAreaTextEditor(chat: chat, r: r, focusedField: $focusedField, editorMode: editorMode)
+ }
+ .background(Color.clear)
+ }
+
+ @MainActor
+ var clearButton: some View {
+ Button(action: {
+ chat.send(.clearButtonTap)
+ }) {
+ Image(systemName: "eraser.line.dashed.fill")
+ .scaledFont(.body)
+ .padding(6)
+ .background {
+ Circle().fill(Color(nsColor: .controlBackgroundColor))
+ }
+ .overlay {
+ Circle().stroke(Color(nsColor: .controlColor), lineWidth: 1)
+ }
+ }
+ .buttonStyle(.plain)
+ }
+}
diff --git a/Core/Sources/ConversationTab/Views/ChatPanelInputArea/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
new file mode 100644
index 00000000..0659dbf8
--- /dev/null
+++ b/Core/Sources/ConversationTab/Views/ChatPanelInputArea/InputAreaTextEditor.swift
@@ -0,0 +1,672 @@
+import ChatService
+import ComposableArchitecture
+import Combine
+import ConversationServiceProvider
+import SwiftUIFlowLayout
+import GitHubCopilotService
+import GitHubCopilotViewModel
+import LanguageServerProtocol
+import Preferences
+import SharedUIComponents
+import Status
+import SwiftUI
+import Workspace
+import XcodeInspector
+
+enum ShowingType { case template, agent }
+
+struct InputAreaTextEditor: View {
+ @Perception.Bindable var chat: StoreOf
+ let r: Double
+ var focusedField: FocusState.Binding
+ let editorMode: Chat.EditorMode
+ @State var cancellable = Set()
+ @State private var isFilePickerPresented = false
+ @State private var allFiles: [ConversationAttachedReference]? = nil
+ @State private var filteredTemplates: [ChatTemplate] = []
+ @State private var filteredAgent: [ChatAgent] = []
+ @State private var showingTemplates = false
+ @State private var dropDownShowingType: ShowingType? = nil
+ @State private var textEditorState: TextEditorState? = nil
+
+ @AppStorage(\.enableCurrentEditorContext) var enableCurrentEditorContext: Bool
+ @State private var isCurrentEditorContextEnabled: Bool = UserDefaults.shared.value(
+ for: \.enableCurrentEditorContext
+ )
+ @ObservedObject private var status: StatusObserver = .shared
+ @State private var isCCRFFEnabled: Bool
+ @State private var isCCRHovering: Bool = false
+ @State private var cancellables = Set()
+
+ @StateObject private var fontScaleManager = FontScaleManager.shared
+
+ var fontScale: Double {
+ fontScaleManager.currentScale
+ }
+
+ init(
+ chat: StoreOf,
+ r: Double,
+ focusedField: FocusState.Binding,
+ editorMode: Chat.EditorMode
+ ) {
+ self.chat = chat
+ self.r = r
+ self.focusedField = focusedField
+ self.editorMode = editorMode
+ self.isCCRFFEnabled = FeatureFlagNotifierImpl.shared.featureFlags.ccr
+ }
+
+ var isEditorActive: Bool {
+ editorMode == chat.editorMode
+ }
+
+ var isRequestingConversation: Bool {
+ if chat.isReceivingMessage,
+ let requestType = chat.requestType,
+ requestType == .conversation {
+ return true
+ }
+ return false
+ }
+
+ var isRequestingCodeReview: Bool {
+ if chat.isReceivingMessage,
+ let requestType = chat.requestType,
+ requestType == .codeReview {
+ return true
+ }
+
+ return false
+ }
+
+ var projectRootURL: URL? {
+ WorkspaceXcodeWindowInspector.extractProjectURL(
+ workspaceURL: chat.workspaceURL,
+ documentURL: chat.state.currentEditor?.url
+ )
+ }
+
+ var body: some View {
+ WithPerceptionTracking {
+ let typedMessage = chat.state.getChatContext(of: editorMode).typedMessage
+ VStack(spacing: 0) {
+ chatContextView
+
+ if isFilePickerPresented {
+ FilePicker(
+ allFiles: $allFiles,
+ workspaceURL: chat.workspaceURL,
+ onSubmit: { ref in
+ chat.send(.addReference(ref))
+ },
+ onExit: {
+ isFilePickerPresented = false
+ focusedField.wrappedValue = .textField
+ }
+ )
+ .onAppear() {
+ allFiles = ContextUtils.getFilesFromWorkspaceIndex(workspaceURL: chat.workspaceURL)
+ }
+ }
+
+ if !chat.state.attachedImages.isEmpty {
+ ImagesScrollView(chat: chat, editorMode: editorMode)
+ }
+
+ ZStack(alignment: .topLeading) {
+ if typedMessage.isEmpty {
+ Group {
+ chat.isAgentMode ?
+ Text("Edit files in your workspace in agent mode") :
+ Text("Ask Copilot or type / for commands")
+ }
+ .scaledFont(size: 14)
+ .foregroundColor(Color(nsColor: .placeholderTextColor))
+ .padding(8)
+ .padding(.horizontal, 4)
+ }
+
+ HStack(spacing: 0) {
+ AutoresizingCustomTextEditor(
+ text: Binding(
+ get: { typedMessage },
+ set: { newValue in chat.send(.updateTypedMessage(newValue)) }
+ ),
+ font: .systemFont(ofSize: 14 * fontScale),
+ isEditable: true,
+ maxHeight: 400,
+ onSubmit: {
+ if (dropDownShowingType == nil) {
+ submitChatMessage()
+ }
+ dropDownShowingType = nil
+ },
+ onTextEditorStateChanged: { (state: TextEditorState?) in
+ DispatchQueue.main.async {
+ textEditorState = state
+ }
+ }
+ )
+ .focused(focusedField, equals: isEditorActive ? .textField : nil)
+ .bind($chat.focusedField, to: focusedField)
+ .padding(8)
+ .fixedSize(horizontal: false, vertical: true)
+ .onChange(of: typedMessage) { newValue in
+ Task {
+ await onTypedMessageChanged(newValue: newValue)
+ }
+ }
+ /// When chat mode changed, the chat tamplate and agent need to be reloaded
+ .onChange(of: chat.isAgentMode) { _ in
+ guard isEditorActive else { return }
+ Task {
+ await onTypedMessageChanged(newValue: typedMessage)
+ }
+ }
+ }
+ .frame(maxWidth: .infinity)
+ }
+ .padding(.top, 4)
+
+ HStack(spacing: 0) {
+ ModeAndModelPicker(projectRootURL: projectRootURL, selectedAgent: $chat.selectedAgent)
+
+ Spacer()
+
+ if let contextSizeInfo = chat.contextSizeInfo {
+ ContextSizeButton(contextSizeInfo: contextSizeInfo)
+ .padding(.trailing, 4)
+ }
+
+ if chat.editorMode.isDefault && !isRequestingConversation {
+ codeReviewButton
+ .buttonStyle(HoverButtonStyle(padding: 0, hoverColor: .clear))
+ .padding(.trailing, 4)
+ }
+
+ ZStack {
+ sendButton
+ .opacity(isRequestingConversation || isRequestingCodeReview ? 0 : 1)
+ .foregroundColor(
+ typedMessage.isEmpty ? Color(nsColor: .tertiaryLabelColor) : Color(
+ "IconStrokeColor"
+ )
+ )
+ .disabled(typedMessage.isEmpty)
+
+ stopButton
+ .opacity(isRequestingConversation || isRequestingCodeReview ? 1 : 0)
+ .foregroundColor(Color("IconStrokeColor"))
+ }
+ .buttonStyle(
+ HoverButtonStyle(
+ padding: 0,
+ hoverColor: Color(nsColor: .quaternaryLabelColor),
+ backgroundColor: Color(nsColor: .quinaryLabel),
+ cornerRadius: .infinity
+ )
+ )
+ }
+ .padding(8)
+ .padding(.top, -4)
+ }
+ .overlay(alignment: .top) {
+ dropdownOverlay
+ }
+ .onAppear() {
+ guard editorMode.isDefault else { return }
+ subscribeToActiveDocumentChangeEvent()
+ // Check quota for CCR
+ Task {
+ if status.quotaInfo == nil,
+ let service = try? GitHubCopilotViewModel.shared.getGitHubCopilotAuthService() {
+ _ = try? await service.checkQuota()
+ }
+ }
+ }
+ .task {
+ subscribeToFeatureFlagsDidChangeEvent()
+ }
+ .background {
+ RoundedRectangle(cornerRadius: 6)
+ .fill(Color(nsColor: .controlBackgroundColor))
+ }
+ .overlay {
+ RoundedRectangle(cornerRadius: 6)
+ .stroke(.quaternary, lineWidth: 1)
+ }
+ .background {
+ if isEditorActive {
+ Button(action: {
+ chat.send(.returnButtonTapped)
+ }) {
+ EmptyView()
+ }
+ .keyboardShortcut(KeyEquivalent.return, modifiers: [.shift])
+ .accessibilityHidden(true)
+
+ Button(action: {
+ focusedField.wrappedValue = .textField
+ }) {
+ EmptyView()
+ }
+ .keyboardShortcut("l", modifiers: [.command])
+ .accessibilityHidden(true)
+
+ buildReloadContextButtons()
+ }
+ }
+
+ }
+ }
+
+ private var reloadNextContextButton: some View {
+ Button(action: {
+ chat.send(.reloadNextContext)
+ }) {
+ EmptyView()
+ }
+ .keyboardShortcut(KeyEquivalent.downArrow, modifiers: [])
+ .accessibilityHidden(true)
+ }
+
+ private var reloadPreviousContextButton: some View {
+ Button(action: {
+ chat.send(.reloadPreviousContext)
+ }) {
+ EmptyView()
+ }
+ .keyboardShortcut(KeyEquivalent.upArrow, modifiers: [])
+ .accessibilityHidden(true)
+ }
+
+ @ViewBuilder
+ private func buildReloadContextButtons() -> some View {
+ if let textEditorState = textEditorState {
+ switch textEditorState {
+ case .empty, .singleLine:
+ ZStack {
+ reloadPreviousContextButton
+ reloadNextContextButton
+ }
+ case .multipleLines(let cursorAt):
+ switch cursorAt {
+ case .first:
+ reloadPreviousContextButton
+ case .last:
+ reloadNextContextButton
+ case .middle:
+ EmptyView()
+ }
+ }
+ } else {
+ EmptyView()
+ }
+ }
+
+ private var sendButton: some View {
+ Button(action: {
+ submitChatMessage()
+ }) {
+ Image(systemName: "paperplane")
+ .scaledFont(size: 12, weight: .medium)
+ .padding(.leading, 5)
+ .padding(.trailing, 6)
+ .padding(.top, 6.5)
+ .padding(.bottom, 5.5)
+ }
+ .keyboardShortcut(KeyEquivalent.return, modifiers: [])
+ .help("Send")
+ }
+
+ private var stopButton: some View {
+ Button(action: {
+ chat.send(.stopRespondingButtonTapped)
+ }) {
+ Image(systemName: "stop.fill")
+ .scaledFont(size: 12, weight: .medium)
+ .padding(8)
+ }
+ .keyboardShortcut(KeyEquivalent.escape, modifiers: [])
+ .help("Stop")
+ }
+
+ private var isFreeUser: Bool {
+ guard let quotaInfo = status.quotaInfo else { return true }
+
+ return quotaInfo.isFreeUser
+ }
+
+ private var ccrDisabledTooltip: String {
+ if !isCCRFFEnabled {
+ return "GitHub Copilot Code Review is disabled by org policy. Contact your admin."
+ }
+
+ return "GitHub Copilot Code Review is temporarily unavailable."
+ }
+
+ var codeReviewIcon: some View {
+ Image("codeReview")
+ .resizable()
+ .scaledToFit()
+ .scaledFrame(width: 14, height: 14)
+ .padding(6)
+ }
+
+ private var codeReviewButton: some View {
+ Group {
+ if isFreeUser {
+ // Show nothing
+ } else if isCCRFFEnabled {
+ Menu {
+ Button(action: {
+ chat.send(.codeReview(.request(.index)))
+ }) {
+ Text("Review Staged Changes")
+ }
+
+ Button(action: {
+ chat.send(.codeReview(.request(.workingTree)))
+ }) {
+ Text("Review Unstaged Changes")
+ }
+ } label: {
+ codeReviewIcon
+ .foregroundColor(isCCRHovering ? .primary : Color("IconStrokeColor"))
+ }
+ .scaledFont(.body)
+ .onHover { hovering in
+ isCCRHovering = hovering
+ }
+ .opacity(isRequestingCodeReview ? 0 : 1)
+ .help("Code Review")
+ } else {
+ codeReviewIcon
+ .foregroundColor(Color(nsColor: .tertiaryLabelColor))
+ .help(ccrDisabledTooltip)
+ }
+ }
+ }
+
+ private func subscribeToFeatureFlagsDidChangeEvent() {
+ FeatureFlagNotifierImpl.shared.featureFlagsDidChange
+ .sink(receiveValue: { isCCRFFEnabled = $0.ccr })
+ .store(in: &cancellables)
+ }
+
+ private var dropdownOverlay: some View {
+ Group {
+ if dropDownShowingType != nil {
+ if dropDownShowingType == .template {
+ ChatDropdownView(items: $filteredTemplates, prefixSymbol: "/") { template in
+ chat.send(.updateTypedMessage("/" + template.id + " "))
+ if template.id == "releaseNotes" {
+ submitChatMessage()
+ }
+ }
+ } else if dropDownShowingType == .agent {
+ ChatDropdownView(items: $filteredAgent, prefixSymbol: "@") { agent in
+ chat.send(.updateTypedMessage("@" + agent.id + " "))
+ }
+ }
+ }
+ }
+ }
+
+ func onTypedMessageChanged(newValue: String) async {
+ guard chat.editorMode.isDefault else { return }
+ if newValue.hasPrefix("/") {
+ filteredTemplates = await chatTemplateCompletion(text: newValue)
+ dropDownShowingType = filteredTemplates.isEmpty ? nil : .template
+ } else if newValue.hasPrefix("@") && !chat.isAgentMode {
+ filteredAgent = await chatAgentCompletion(text: newValue)
+ dropDownShowingType = filteredAgent.isEmpty ? nil : .agent
+ } else {
+ dropDownShowingType = nil
+ }
+ }
+
+ enum ChatContextButtonType { case imageAttach, contextAttach}
+
+ private var chatContextView: some View {
+ let buttonItems: [ChatContextButtonType] = [.contextAttach, .imageAttach]
+ // Always use the latest current editor from state
+ let currentEditorItem: [ConversationFileReference] = [chat.state.currentEditor].compactMap {
+ $0
+ }
+ let references = chat.state.getChatContext(of: editorMode).attachedReferences
+ let chatContextItems: [Any] = buttonItems.map {
+ $0 as ChatContextButtonType
+ } + currentEditorItem + references
+ return FlowLayout(mode: .scrollable, items: chatContextItems, itemSpacing: 4) { item in
+ if let buttonType = item as? ChatContextButtonType {
+ if buttonType == .imageAttach {
+ VisionMenuView(chat: chat)
+ } else if buttonType == .contextAttach {
+ // File picker button
+ Button(action: {
+ withAnimation {
+ isFilePickerPresented.toggle()
+ if !isFilePickerPresented {
+ focusedField.wrappedValue = .textField
+ }
+ }
+ }) {
+ Image(systemName: "paperclip")
+ .resizable()
+ .aspectRatio(contentMode: .fill)
+ .scaledFrame(width: 16, height: 16)
+ .scaledPadding(4)
+ .foregroundColor(.primary.opacity(0.85))
+ .scaledFont(size: 11, weight: .semibold)
+ }
+ .buttonStyle(HoverButtonStyle(padding: 0))
+ .help("Add Context")
+ .cornerRadius(6)
+ }
+ } else if let select = item as? ConversationFileReference, select.isCurrentEditor {
+ makeCurrentEditorView(select)
+ } else if let select = item as? ConversationAttachedReference {
+ makeReferenceItemView(select)
+ }
+ }
+ .padding(.horizontal, 8)
+ .padding(.top, 8)
+ }
+
+ @ViewBuilder
+ func makeCurrentEditorView(_ ref: ConversationFileReference) -> some View {
+ let toggleTrailingPadding: CGFloat = {
+ if #available(macOS 26.0, *) {
+ return 8
+ } else {
+ return 4
+ }
+ }()
+
+ HStack(alignment: .center, spacing: 0) {
+ makeContextFileNameView(url: ref.url, isCurrentEditor: true, selection: ref.selection)
+
+ Toggle("", isOn: $isCurrentEditorContextEnabled)
+ .toggleStyle(SwitchToggleStyle(tint: .blue))
+ .controlSize(.mini)
+ .frame(width: 34)
+ .padding(.trailing, toggleTrailingPadding)
+ .onChange(of: isCurrentEditorContextEnabled) { newValue in
+ enableCurrentEditorContext = newValue
+ }
+ }
+ .chatContextReferenceStyle(isCurrentEditor: true, r: r)
+ }
+
+ @ViewBuilder
+ func makeReferenceItemView(_ ref: ConversationAttachedReference) -> some View {
+ HStack(spacing: 0) {
+ makeContextFileNameView(url: ref.url, isCurrentEditor: false, isDirectory: ref.isDirectory)
+
+ Button(action: { chat.send(.removeReference(ref)) }) {
+ Image(systemName: "xmark")
+ .resizable()
+ .scaledFrame(width: 8, height: 8)
+ .foregroundColor(.primary.opacity(0.85))
+ .padding(4)
+ }
+ .buttonStyle(HoverButtonStyle())
+ }
+ .chatContextReferenceStyle(isCurrentEditor: false, r: r)
+ }
+
+ @ViewBuilder
+ func makeContextFileNameView(
+ url: URL,
+ isCurrentEditor: Bool,
+ isDirectory: Bool = false,
+ selection: LSPRange? = nil
+ ) -> some View {
+ drawFileIcon(url, isDirectory: isDirectory)
+ .scaledToFit()
+ .scaledFrame(width: 16, height: 16)
+ .foregroundColor(.primary.opacity(0.85))
+ .padding(4)
+ .opacity(isCurrentEditor && !isCurrentEditorContextEnabled ? 0.4 : 1.0)
+
+ HStack(spacing: 0) {
+ Text(url.lastPathComponent)
+
+ Group {
+ if isCurrentEditor, let selection {
+ let startLine = selection.start.line
+ let endLine = selection.end.line
+ if startLine == endLine {
+ Text(String(format: ":%d", selection.start.line + 1))
+ } else {
+ Text(String(format: ":%d-%d", selection.start.line + 1, selection.end.line + 1))
+ }
+ }
+ }
+ .foregroundColor(.secondary)
+ }
+ .lineLimit(1)
+ .truncationMode(.middle)
+ .foregroundColor(
+ isCurrentEditor && !isCurrentEditorContextEnabled
+ ? .secondary
+ : .primary.opacity(0.85)
+ )
+ .scaledFont(.body)
+ .opacity(isCurrentEditor && !isCurrentEditorContextEnabled ? 0.4 : 1.0)
+ .help(url.getPathRelativeToHome())
+ }
+
+ func chatTemplateCompletion(text: String) async -> [ChatTemplate] {
+ guard text.count >= 1 && text.first == "/" else { return [] }
+
+ let prefix = String(text.dropFirst()).lowercased()
+ let promptTemplates: [ChatTemplate] = await SharedChatService.shared.loadChatTemplates() ?? []
+ let releaseNotesTemplate: ChatTemplate = .init(
+ id: "releaseNotes",
+ description: "What's New",
+ shortDescription: "What's New",
+ scopes: [.chatPanel, .agentPanel]
+ )
+
+ let templates = promptTemplates + [releaseNotesTemplate]
+ let skippedTemplates = [ "feedback", "help" ]
+
+ return templates.filter {
+ $0.scopes.contains(chat.isAgentMode ? .agentPanel : .chatPanel) &&
+ $0.id.lowercased().hasPrefix(prefix) &&
+ !skippedTemplates.contains($0.id)
+ }
+ }
+
+ func chatAgentCompletion(text: String) async -> [ChatAgent] {
+ guard text.count >= 1 && text.first == "@" else { return [] }
+ let prefix = text.dropFirst()
+ var chatAgents = await SharedChatService.shared.loadChatAgents() ?? []
+
+ if let index = chatAgents.firstIndex(where: { $0.slug == "project" }) {
+ let projectAgent = chatAgents[index]
+ chatAgents[index] = .init(slug: "workspace", name: "workspace", description: "Ask about your workspace", avatarUrl: projectAgent.avatarUrl)
+ }
+
+ /// only enable the @workspace
+ let includedAgents = ["workspace"]
+
+ return chatAgents.filter { $0.slug.hasPrefix(prefix) && includedAgents.contains($0.slug) }
+ }
+
+ func subscribeToActiveDocumentChangeEvent() {
+ var task: Task?
+ var currentFocusedEditor: SourceEditor?
+
+ Publishers.CombineLatest3(
+ XcodeInspector.shared.$latestActiveXcode,
+ XcodeInspector.shared.$activeDocumentURL
+ .removeDuplicates(),
+ XcodeInspector.shared.$focusedEditor
+ .removeDuplicates()
+ )
+ .receive(on: DispatchQueue.main)
+ .sink { newXcode, newDocURL, newFocusedEditor in
+ var currentEditor: ConversationFileReference?
+
+ // First check for realtimeWorkspaceURL if activeWorkspaceURL is nil
+ if let realtimeURL = newXcode?.realtimeDocumentURL, newDocURL == nil {
+ if supportedFileExtensions.contains(realtimeURL.pathExtension) {
+ currentEditor = ConversationFileReference(url: realtimeURL, isCurrentEditor: true)
+ }
+ } else if let docURL = newDocURL, supportedFileExtensions.contains(newDocURL?.pathExtension ?? "") {
+ currentEditor = ConversationFileReference(url: docURL, isCurrentEditor: true)
+ }
+
+ if var currentEditor = currentEditor {
+ if let selection = newFocusedEditor?.getContent().selections.first,
+ selection.start != selection.end {
+ currentEditor.selection = .init(start: selection.start, end: selection.end)
+ }
+
+ chat.send(.setCurrentEditor(currentEditor))
+ }
+
+ if currentFocusedEditor != newFocusedEditor {
+ task?.cancel()
+ task = nil
+ currentFocusedEditor = newFocusedEditor
+
+ if let editor = currentFocusedEditor {
+ task = Task { @MainActor in
+ for await _ in await editor.axNotifications.notifications()
+ .filter({ $0.kind == .selectedTextChanged }) {
+ handleSourceEditorSelectionChanged(editor)
+ }
+ }
+ }
+ }
+ }
+ .store(in: &cancellable)
+ }
+
+ private func handleSourceEditorSelectionChanged(_ sourceEditor: SourceEditor) {
+ guard let fileURL = sourceEditor.realtimeDocumentURL,
+ let currentEditorURL = chat.currentEditor?.url,
+ fileURL == currentEditorURL
+ else {
+ return
+ }
+
+ var currentEditor: ConversationFileReference = .init(url: fileURL, isCurrentEditor: true)
+
+ if let selection = sourceEditor.getContent().selections.first,
+ selection.start != selection.end {
+ currentEditor.selection = .init(start: selection.start, end: selection.end)
+ }
+
+ chat.send(.setCurrentEditor(currentEditor))
+ }
+
+ func submitChatMessage() {
+ chat.send(.sendButtonTapped(UUID().uuidString))
+ }
+}
diff --git a/Core/Sources/ConversationTab/Views/CheckPoint.swift b/Core/Sources/ConversationTab/Views/CheckPoint.swift
new file mode 100644
index 00000000..5c6e4a86
--- /dev/null
+++ b/Core/Sources/ConversationTab/Views/CheckPoint.swift
@@ -0,0 +1,238 @@
+import SwiftUI
+import ComposableArchitecture
+import SharedUIComponents
+import AppKit
+
+struct CheckPoint: View {
+ let chat: StoreOf
+ let messageId: String
+
+ @State private var isHovering: Bool = false
+ @State private var window: NSWindow?
+ @AppStorage(\.chatFontSize) var chatFontSize
+ @AppStorage(\.suppressRestoreCheckpointConfirmation) var suppressRestoreCheckpointConfirmation
+ @Environment(\.colorScheme) var colorScheme
+
+ private var isPendingCheckpoint: Bool {
+ chat.pendingCheckpointMessageId == messageId
+ }
+
+ var body: some View {
+ WithPerceptionTracking {
+ HStack(spacing: 4) {
+ checkpointIcon
+
+ checkpointLine
+ .overlay(alignment: .leading) {
+ checkpointContent
+ }
+ }
+ .scaledFrame(height: chatFontSize)
+ .onHover { isHovering = $0 }
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel(accessibilityLabel)
+ .background(WindowAccessor { window in
+ // Store window reference for later use
+ self.window = window
+ })
+ }
+ }
+
+ var checkpointIcon: some View {
+ Image(systemName: "bookmark")
+ .resizable()
+ .scaledToFit()
+ .scaledFrame(width: chatFontSize, height: chatFontSize)
+ .foregroundStyle(.secondary)
+ }
+
+ var checkpointLine: some View {
+ DashedLine()
+ .stroke(style: StrokeStyle(dash: [3]))
+ .foregroundStyle(.gray)
+ .scaledFrame(height: 1)
+ }
+
+ @ViewBuilder
+ var checkpointContent: some View {
+ HStack(spacing: 12) {
+ if isPendingCheckpoint {
+ HStack(spacing: 12) {
+ undoButton
+
+ Text("Checkpoint Restored")
+ .scaledFont(size: chatFontSize)
+ .foregroundStyle(.secondary)
+ .scaledPadding(.horizontal, 2)
+ .background(Color.chatWindowBackgroundColor)
+ }
+ } else if isHovering {
+ restoreButton
+ .transition(.opacity.combined(with: .move(edge: .leading)))
+ }
+
+ Spacer()
+ }
+ }
+
+ var hasSubsequentFileEdit: Bool {
+ for message in chat.state.getMessages(after: messageId, through: chat.pendingCheckpointMessageId) {
+ if !message.fileEdits.isEmpty {
+ return true
+ }
+ }
+
+ return false
+ }
+
+ var restoreButton: some View {
+ ActionButton(
+ title: "Restore Checkpoint",
+ helpText: "Restore workspace and chat to this point",
+ action: {
+ if !suppressRestoreCheckpointConfirmation && hasSubsequentFileEdit {
+ showRestoreAlert()
+ } else {
+ handleRestore()
+ }
+ }
+ )
+ }
+
+ func handleRestore() {
+ Task { @MainActor in
+ await chat.send(.restoreCheckPoint(messageId)).finish()
+ }
+ }
+
+ var undoButton: some View {
+ ActionButton(
+ title: "Undo",
+ helpText: "Reapply discarded workspace changes and chat",
+ action: {
+ Task { @MainActor in
+ await chat.send(.undoCheckPoint).finish()
+ }
+ }
+ )
+ }
+
+ var accessibilityLabel: String {
+ if isPendingCheckpoint {
+ "Checkpoint restored. Tap to redo changes."
+ } else {
+ "Checkpoint. Tap to restore to this point."
+ }
+ }
+
+ func showRestoreAlert() {
+ let alert = NSAlert()
+ alert.messageText = "Restore Checkpoint"
+ alert.informativeText = "This will remove all subsequent requests and edits. Do you want to proceed?"
+
+ alert.addButton(withTitle: "Restore")
+ alert.addButton(withTitle: "Cancel")
+
+ alert.showsSuppressionButton = true
+ alert.suppressionButton?.title = "Don't ask again"
+
+ alert.alertStyle = .warning
+
+ let targetWindow = window ?? NSApplication.shared.keyWindow ?? NSApplication.shared.windows.first {
+ $0.isVisible
+ }
+
+ if let targetWindow = targetWindow {
+ alert.beginSheetModal(for: targetWindow) { response in
+ self.handleAlertResponse(response, alert: alert)
+ }
+ } else {
+ let response = alert.runModal()
+ handleAlertResponse(response, alert: alert)
+ }
+ }
+
+ private func handleAlertResponse(_ response: NSApplication.ModalResponse, alert: NSAlert) {
+ if response == .alertFirstButtonReturn {
+ handleRestore()
+ }
+
+ suppressRestoreCheckpointConfirmation = alert.suppressionButton?.state == .on
+ }
+}
+
+private struct ActionButton: View {
+ let title: String
+ let helpText: String
+ let action: () -> Void
+
+ @Environment(\.colorScheme) private var colorScheme
+ @AppStorage(\.chatFontSize) private var chatFontSize
+
+ private var adaptiveTextColor: Color {
+ colorScheme == .light ? .black.opacity(0.75) : .white.opacity(0.75)
+ }
+
+ var body: some View {
+ Button(action: action) {
+ Text(title)
+ .scaledFont(.footnote)
+ .scaledPadding(4)
+ .foregroundStyle(adaptiveTextColor)
+ }
+ .background(
+ RoundedRectangle(cornerRadius: 4)
+ .fill(Color(nsColor: .windowBackgroundColor))
+ .overlay(
+ RoundedRectangle(cornerRadius: 4)
+ .stroke(.gray, lineWidth: 0.5)
+ )
+ )
+ .buttonStyle(HoverButtonStyle(padding: 0))
+ .scaledPadding(.leading, 8)
+ .help(helpText)
+ .accessibilityLabel(title)
+ .accessibilityHint(helpText)
+ }
+}
+
+private struct DashedLine: Shape {
+ func path(in rect: CGRect) -> Path {
+ var path = Path()
+ path.move(to: CGPoint(x: rect.minX, y: rect.midY))
+ path.addLine(to: CGPoint(x: rect.maxX, y: rect.midY))
+ return path
+ }
+}
+
+struct WindowAccessor: NSViewRepresentable {
+ var callback: (NSWindow?) -> Void
+
+ func makeNSView(context: Context) -> NSView {
+ return WindowTrackingView(callback: callback)
+ }
+
+ func updateNSView(_ nsView: NSView, context: Context) {
+ if let windowTrackingView = nsView as? WindowTrackingView {
+ windowTrackingView.callback = callback
+ }
+ }
+}
+
+private class WindowTrackingView: NSView {
+ var callback: (NSWindow?) -> Void
+
+ init(callback: @escaping (NSWindow?) -> Void) {
+ self.callback = callback
+ super.init(frame: .zero)
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func viewDidMoveToWindow() {
+ super.viewDidMoveToWindow()
+ callback(window)
+ }
+}
diff --git a/Core/Sources/ConversationTab/Views/CodeReviewRound/CodeReviewMainView.swift b/Core/Sources/ConversationTab/Views/CodeReviewRound/CodeReviewMainView.swift
index a7f1b68b..27256b87 100644
--- a/Core/Sources/ConversationTab/Views/CodeReviewRound/CodeReviewMainView.swift
+++ b/Core/Sources/ConversationTab/Views/CodeReviewRound/CodeReviewMainView.swift
@@ -2,6 +2,7 @@ import ComposableArchitecture
import ConversationServiceProvider
import LanguageServerProtocol
import SwiftUI
+import SharedUIComponents
// MARK: - Main View
@@ -34,56 +35,10 @@ struct CodeReviewMainView: View {
var helloMessageView: some View {
Text(Self.HelloMessage)
- .font(.system(size: chatFontSize))
- }
-
- var statusIcon: some View {
- Group {
- switch round.status {
- case .running:
- ProgressView()
- .controlSize(.small)
- .frame(width: 16, height: 16)
- .scaleEffect(0.7)
- case .completed:
- Image(systemName: "checkmark")
- .foregroundColor(.green)
- case .error:
- Image(systemName: "xmark.circle")
- .foregroundColor(.red)
- case .cancelled:
- Image(systemName: "slash.circle")
- .foregroundColor(.gray)
- case .waitForConfirmation:
- EmptyView()
- case .accepted:
- EmptyView()
- }
- }
- }
-
- var statusView: some View {
- Group {
- switch round.status {
- case .waitForConfirmation, .accepted:
- EmptyView()
- default:
- HStack(spacing: 4) {
- statusIcon
- .frame(width: 16, height: 16)
-
- Text("Running Code Review...")
- .font(.system(size: chatFontSize))
- .foregroundColor(.secondary)
-
- Spacer()
- }
- }
- }
+ .scaledFont(.system(size: chatFontSize))
}
var shouldShowHelloMessage: Bool { round.statusHistory.contains(.waitForConfirmation) }
- var shouldShowRunningStatus: Bool { round.statusHistory.contains(.running) }
var body: some View {
WithPerceptionTracking {
@@ -101,10 +56,6 @@ struct CodeReviewMainView: View {
)
}
- if shouldShowRunningStatus {
- statusView
- }
-
if hasFileComments {
ReviewResultsSection(store: store, round: round)
}
diff --git a/Core/Sources/ConversationTab/Views/CodeReviewRound/FileSelectionSection.swift b/Core/Sources/ConversationTab/Views/CodeReviewRound/FileSelectionSection.swift
index abfb3478..bc044fa7 100644
--- a/Core/Sources/ConversationTab/Views/CodeReviewRound/FileSelectionSection.swift
+++ b/Core/Sources/ConversationTab/Views/CodeReviewRound/FileSelectionSection.swift
@@ -46,12 +46,13 @@ private struct FileSelectionHeader: View {
var body: some View {
HStack(alignment: .top, spacing: 6) {
- Image("Sparkle")
+ Image("codeReview")
.resizable()
- .frame(width: 16, height: 16)
+ .scaledToFit()
+ .scaledFrame(width: 16, height: 16)
Text("Youโve selected following \(fileCount) file(s) with code changes. Review them or unselect any files you don't need, then click Continue.")
- .font(.system(size: chatFontSize))
+ .scaledFont(.system(size: chatFontSize))
.multilineTextAlignment(.leading)
}
}
@@ -69,12 +70,14 @@ private struct FileSelectionActions: View {
}
.buttonStyle(.bordered)
.controlSize(.large)
+ .scaledFont(.body)
Button("Continue") {
store.send(.codeReview(.accept(id: roundId, selectedFiles: selectedFileUris)))
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
+ .scaledFont(.body)
}
}
}
@@ -89,6 +92,11 @@ private struct FileSelectionList: View {
@State private var checkboxMixedState: CheckboxMixedState = .off
@Binding var selectedFileUris: [DocumentUri]
@AppStorage(\.chatFontSize) private var chatFontSize
+ @StateObject private var fontScaleManager = FontScaleManager.shared
+
+ var fontScale: Double {
+ fontScaleManager.currentScale
+ }
private static let defaultVisibleFileCount = 5
@@ -105,6 +113,7 @@ private struct FileSelectionList: View {
// Select All checkbox for all files
selectedAllCheckbox
.disabled(reviewStatus != .waitForConfirmation)
+ .scaledFrame(maxHeight: 16)
FileToggleList(
fileUris: visibleFileUris,
@@ -141,9 +150,11 @@ private struct FileSelectionList: View {
let selectedCount = selectedFileUris.count
let totalCount = fileUris.count
let title = "All (\(selectedCount)/\(totalCount))"
+ let font: NSFont = .systemFont(ofSize: chatFontSize * fontScale)
return MixedStateCheckbox(
title: title,
+ font: font,
state: $checkboxMixedState
) {
switch checkboxMixedState {
@@ -181,12 +192,13 @@ private struct ExpandFilesButton: View {
HStack(spacing: 2) {
Image("chevron.down")
.resizable()
- .frame(width: 16, height: 16)
+ .scaledToFit()
+ .scaledFrame(width: 16, height: 16)
Button(action: { isExpanded = true }) {
Text("Show more")
- .font(.system(size: chatFontSize))
.underline()
+ .scaledFont(.system(size: chatFontSize))
.lineSpacing(20)
}
.buttonStyle(PlainButtonStyle())
@@ -243,15 +255,15 @@ private struct FileSelectionRow: View {
}
var body: some View {
- HStack {
+ HStack(alignment: .center) {
Toggle(isOn: $isSelected) {
HStack(spacing: 8) {
drawFileIcon(fileURL)
- .resizable()
.scaledToFit()
- .frame(width: 16, height: 16)
+ .scaledFrame(width: 16, height: 16)
Text(fileURL?.lastPathComponent ?? fileUri)
+ .scaledFont(.body)
.lineLimit(1)
.truncationMode(.middle)
}
diff --git a/Core/Sources/ConversationTab/Views/CodeReviewRound/ReviewResultsSection.swift b/Core/Sources/ConversationTab/Views/CodeReviewRound/ReviewResultsSection.swift
index d2e74d9d..1a239021 100644
--- a/Core/Sources/ConversationTab/Views/CodeReviewRound/ReviewResultsSection.swift
+++ b/Core/Sources/ConversationTab/Views/CodeReviewRound/ReviewResultsSection.swift
@@ -60,7 +60,7 @@ private struct ReviewResultsHeader: View {
var body: some View {
HStack(spacing: 4) {
Text("Reviewed Changes")
- .font(.system(size: chatFontSize))
+ .scaledFont(size: chatFontSize)
Spacer()
}
@@ -80,7 +80,8 @@ private struct ExpandReviewsButton: View {
} label: {
Image("chevron.down")
.resizable()
- .frame(width: 16, height: 16)
+ .scaledToFit()
+ .scaledFrame(width: 16, height: 16)
}
.buttonStyle(PlainButtonStyle())
@@ -146,10 +147,10 @@ private struct ReviewResultRowContent: View {
@AppStorage(\.chatFontSize) private var chatFontSize
var body: some View {
- HStack(spacing: 4) {
+ HStack(alignment: .center, spacing: 4) {
drawFileIcon(fileURL)
- .resizable()
- .frame(width: 16, height: 16)
+ .scaledToFit()
+ .scaledFrame(width: 16, height: 16)
Button(action: {
if hasComments {
@@ -157,7 +158,7 @@ private struct ReviewResultRowContent: View {
}
}) {
Text(fileURL.lastPathComponent)
- .font(.system(size: chatFontSize))
+ .scaledFont(.system(size: chatFontSize))
.foregroundColor(isHovered ? Color("ItemSelectedColor") : .primary)
}
.buttonStyle(PlainButtonStyle())
@@ -172,7 +173,7 @@ private struct ReviewResultRowContent: View {
}
Text(commentCountText)
- .font(.system(size: chatFontSize - 1))
+ .scaledFont(size: chatFontSize - 1)
.lineSpacing(20)
.foregroundColor(.secondary)
diff --git a/Core/Sources/ConversationTab/Views/CodeReviewRound/ReviewSummarySection.swift b/Core/Sources/ConversationTab/Views/CodeReviewRound/ReviewSummarySection.swift
index e924f1bb..76fcbf6d 100644
--- a/Core/Sources/ConversationTab/Views/CodeReviewRound/ReviewSummarySection.swift
+++ b/Core/Sources/ConversationTab/Views/CodeReviewRound/ReviewSummarySection.swift
@@ -1,19 +1,24 @@
import SwiftUI
import ConversationServiceProvider
+import SharedUIComponents
struct ReviewSummarySection: View {
var round: CodeReviewRound
@AppStorage(\.chatFontSize) var chatFontSize
var body: some View {
- if round.status == .error, let errorMessage = round.error {
- Text(errorMessage)
- .font(.system(size: chatFontSize))
- } else if round.status == .completed, let request = round.request, let response = round.response {
- CompletedSummary(request: request, response: response)
- } else {
- Text("Oops, failed to review changes.")
- .font(.system(size: chatFontSize))
+ HStack {
+ if round.status == .error, let errorMessage = round.error {
+ Text(errorMessage)
+ .scaledFont(size: chatFontSize)
+ } else if round.status == .completed, let request = round.request, let response = round.response {
+ CompletedSummary(request: request, response: response)
+ } else {
+ Text("Oops, failed to review changes.")
+ .font(.system(size: chatFontSize))
+ }
+
+ Spacer()
}
}
}
@@ -39,6 +44,6 @@ struct CompletedSummary: View {
}
}
- .font(.system(size: chatFontSize))
+ .scaledFont(size: chatFontSize)
}
}
diff --git a/Core/Sources/ConversationTab/Views/ConversationAgentProgressView.swift b/Core/Sources/ConversationTab/Views/ConversationAgentProgressView.swift
deleted file mode 100644
index cf4b8a61..00000000
--- a/Core/Sources/ConversationTab/Views/ConversationAgentProgressView.swift
+++ /dev/null
@@ -1,221 +0,0 @@
-import SwiftUI
-import ConversationServiceProvider
-import ComposableArchitecture
-import Combine
-import ChatTab
-import ChatService
-
-struct ProgressAgentRound: View {
- let rounds: [AgentRound]
- let chat: StoreOf
-
- var body: some View {
- WithPerceptionTracking {
- VStack(alignment: .leading, spacing: 4) {
- ForEach(rounds, id: \.roundId) { round in
- VStack(alignment: .leading, spacing: 4) {
- ThemedMarkdownText(text: round.reply, chat: chat)
- if let toolCalls = round.toolCalls, !toolCalls.isEmpty {
- ProgressToolCalls(tools: toolCalls, chat: chat)
- .padding(.vertical, 8)
- }
- }
- }
- }
- .foregroundStyle(.secondary)
- }
- }
-}
-
-struct ProgressToolCalls: View {
- let tools: [AgentToolCall]
- let chat: StoreOf
-
- var body: some View {
- WithPerceptionTracking {
- VStack(alignment: .leading, spacing: 4) {
- ForEach(tools) { tool in
- if tool.name == ToolName.runInTerminal.rawValue && tool.invokeParams != nil {
- RunInTerminalToolView(tool: tool, chat: chat)
- } else if tool.invokeParams != nil && tool.status == .waitForConfirmation {
- ToolConfirmationView(tool: tool, chat: chat)
- } else {
- ToolStatusItemView(tool: tool)
- }
- }
- }
- }
- }
-}
-
-struct ToolConfirmationView: View {
- let tool: AgentToolCall
- let chat: StoreOf
-
- @AppStorage(\.chatFontSize) var chatFontSize
-
- var body: some View {
- WithPerceptionTracking {
- VStack(alignment: .leading, spacing: 8) {
- GenericToolTitleView(toolStatus: "Run", toolName: tool.name, fontWeight: .semibold)
-
- ThemedMarkdownText(text: tool.invokeParams?.message ?? "", chat: chat)
- .frame(maxWidth: .infinity, alignment: .leading)
-
- HStack {
- Button("Cancel") {
- chat.send(.toolCallCancelled(tool.id))
- }
-
- Button("Continue") {
- chat.send(.toolCallAccepted(tool.id))
- }
- .buttonStyle(BorderedProminentButtonStyle())
- }
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding(.top, 4)
- }
- .padding(8)
- .cornerRadius(8)
- .overlay(
- RoundedRectangle(cornerRadius: 8)
- .stroke(Color.gray.opacity(0.2), lineWidth: 1)
- )
- }
- }
-}
-
-struct GenericToolTitleView: View {
- var toolStatus: String
- var toolName: String
- var fontWeight: Font.Weight = .regular
-
- @AppStorage(\.chatFontSize) var chatFontSize
-
- var body: some View {
- HStack(spacing: 4) {
- Text(toolStatus)
- .textSelection(.enabled)
- .font(.system(size: chatFontSize, weight: fontWeight))
- .foregroundStyle(.primary)
- .background(Color.clear)
- Text(toolName)
- .textSelection(.enabled)
- .font(.system(size: chatFontSize, weight: fontWeight))
- .foregroundStyle(.primary)
- .padding(.vertical, 2)
- .padding(.horizontal, 4)
- .background(Color("ToolTitleHighlightBgColor"))
- .cornerRadius(4)
- .overlay(
- RoundedRectangle(cornerRadius: 4)
- .inset(by: 0.5)
- .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
- )
- }
- .frame(maxWidth: .infinity, alignment: .leading)
- }
-}
-
-struct ToolStatusItemView: View {
-
- let tool: AgentToolCall
-
- @AppStorage(\.chatFontSize) var chatFontSize
-
- var statusIcon: some View {
- Group {
- switch tool.status {
- case .running:
- ProgressView()
- .controlSize(.small)
- .scaleEffect(0.7)
- case .completed:
- Image(systemName: "checkmark")
- .foregroundColor(.green.opacity(0.5))
- 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()
- }
- }
- }
-
- 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
- .frame(width: 16, height: 16)
-
- progressTitleText
- .font(.system(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 7b6c845e..739b126a 100644
--- a/Core/Sources/ConversationTab/Views/ConversationProgressStepView.swift
+++ b/Core/Sources/ConversationTab/Views/ConversationProgressStepView.swift
@@ -3,6 +3,7 @@ import ConversationServiceProvider
import ComposableArchitecture
import Combine
import ChatService
+import SharedUIComponents
struct ProgressStep: View {
let steps: [ConversationProgressStep]
@@ -30,11 +31,10 @@ struct StatusItemView: View {
case .running:
ProgressView()
.controlSize(.small)
- .frame(width: 16, height: 16)
- .scaleEffect(0.7)
+ .scaledScaleEffect(0.7)
case .completed:
Image(systemName: "checkmark")
- .foregroundColor(.green)
+ .foregroundColor(Color.successLightGreen)
case .failed:
Image(systemName: "xmark.circle")
.foregroundColor(.red)
@@ -43,28 +43,29 @@ struct StatusItemView: View {
.foregroundColor(.gray)
}
}
+ .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 {
WithPerceptionTracking {
HStack(spacing: 4) {
statusIcon
- .frame(width: 16, height: 16)
+ .scaledFrame(width: 16, height: 16)
- statusTitle
- .font(.system(size: chatFontSize))
+ Text(statusTitleText)
+ .scaledFont(size: chatFontSize - 1)
.lineLimit(1)
Spacer()
}
+ .help(statusTitleText)
}
}
}
diff --git a/Core/Sources/ConversationTab/Views/FunctionMessage.swift b/Core/Sources/ConversationTab/Views/FunctionMessage.swift
index 8fbd6ac9..5686f0e9 100644
--- a/Core/Sources/ConversationTab/Views/FunctionMessage.swift
+++ b/Core/Sources/ConversationTab/Views/FunctionMessage.swift
@@ -19,23 +19,49 @@ struct FunctionMessage: View {
private var isOrgUser: Bool {
text.contains("reach out to your organization's Copilot admin")
}
+
+ private var isBYOKUser: Bool {
+ text.contains("You've reached your quota limit for your BYOK model")
+ }
+
+ private var 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) {
- case (true, _):
+ switch (isFreePlanUser, isOrgUser, isBYOKUser) {
+ case (true, _, _):
return Text("Monthly message limit reached. Upgrade to Copilot Pro (30-day free trial) or wait for your limit to reset.")
-
- case (_, true):
+
+ case (_, true, _):
let parts = [
"You have exceeded your free request allowance.",
switchToFallbackModelText,
@@ -43,12 +69,14 @@ struct FunctionMessage: View {
].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 }
+ return Text(attributedString(from: parts))
+
default:
- let parts = [
- "You have exceeded your premium request allowance.",
- switchToFallbackModelText,
- "[Enable additional paid premium requests](https://aka.ms/github-copilot-manage-overage) to continue using premium models."
- ].filter { !$0.isEmpty }
+ let parts = [text, switchToFallbackModelText].filter { !$0.isEmpty }
return Text(attributedString(from: parts))
}
}
@@ -61,10 +89,25 @@ struct FunctionMessage: View {
}
}
+ private func splitBYOKQuotaMessage(_ message: String) -> [String] {
+ // Fast path: find the first period followed by a space + capital P (for "Please")
+ let boundary = ". Please check with"
+ if let range = message.range(of: boundary) {
+ // First sentence ends at the period just before " Please"
+ let firstSentence = String(message[.. String {
switch item.source {
@@ -22,41 +24,32 @@ struct ImageReferenceItemView: View {
}
var body: some View {
+ // The HStack arranges its child views horizontally with a right-to-left layout direction applied via `.environment(\.layoutDirection, .rightToLeft)`.
+ // This ensures the views are displayed in reverse order to match the desired layout for FlowLayout.
HStack(alignment: .center, spacing: 4) {
- let image = loadImageFromData(data: item.data).image
- image
- .resizable()
- .aspectRatio(contentMode: .fill)
- .frame(width: 28, height: 28)
- .clipShape(RoundedRectangle(cornerRadius: 1.72))
- .overlay(
- RoundedRectangle(cornerRadius: 1.72)
- .inset(by: 0.21)
- .stroke(Color(nsColor: .separatorColor), lineWidth: 0.43)
- )
-
let text = getImageTitle()
- let font = NSFont.systemFont(ofSize: 12)
- let attributes = [NSAttributedString.Key.font: font]
- let size = (text as NSString).size(withAttributes: attributes)
- let textWidth = min(size.width, 105)
Text(text)
.lineLimit(1)
- .font(.system(size: 12))
- .foregroundColor(.primary.opacity(0.85))
+ .scaledFont(size: 12)
.truncationMode(.middle)
- .frame(width: textWidth, alignment: .leading)
+ .scaledFrame(maxWidth: 105, alignment: .center)
+ .fixedSize(horizontal: true, vertical: false)
+
+ Image(systemName: "photo")
+ .resizable()
+ .scaledToFit()
+ .scaledPadding(.vertical, 2)
+ .scaledFrame(width: 16, height: 16)
}
- .padding(4)
- .background(
- Color(nsColor: .windowBackgroundColor).opacity(0.5)
- )
- .cornerRadius(4)
+ .foregroundColor(.primary.opacity(0.85))
+ .scaledPadding(.horizontal, 4)
+ .scaledPadding(.vertical, 1)
+ .cornerRadius(6)
.overlay(
- RoundedRectangle(cornerRadius: 4)
+ RoundedRectangle(cornerRadius: 6)
.inset(by: 0.5)
- .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
+ .stroke(Color(nsColor: .quaternaryLabelColor), lineWidth: 1 * fontScale)
)
.popover(isPresented: $showPopover, arrowEdge: .bottom) {
PopoverImageView(data: item.data)
diff --git a/Core/Sources/ConversationTab/Views/NotificationBanner.swift b/Core/Sources/ConversationTab/Views/NotificationBanner.swift
index 68c40d57..89062b0a 100644
--- a/Core/Sources/ConversationTab/Views/NotificationBanner.swift
+++ b/Core/Sources/ConversationTab/Views/NotificationBanner.swift
@@ -1,16 +1,20 @@
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
}
}
@@ -18,27 +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)
- .padding(.vertical, 10)
- .padding(.horizontal, 12)
+ .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)
)
- .padding(.vertical, 4)
}
}
diff --git a/Core/Sources/ConversationTab/Views/ThemedMarkdownText.swift b/Core/Sources/ConversationTab/Views/ThemedMarkdownText.swift
index 3af730eb..f9a1409b 100644
--- a/Core/Sources/ConversationTab/Views/ThemedMarkdownText.swift
+++ b/Core/Sources/ConversationTab/Views/ThemedMarkdownText.swift
@@ -5,6 +5,7 @@ import ChatService
import ComposableArchitecture
import SuggestionBasic
import ChatTab
+import SharedUIComponents
public struct MarkdownActionProvider {
let supportInsert: Bool
@@ -23,15 +24,33 @@ public struct ThemedMarkdownText: View {
@AppStorage(\.codeForegroundColorDark) var codeForegroundColorDark
@AppStorage(\.codeBackgroundColorDark) var codeBackgroundColorDark
@AppStorage(\.chatFontSize) var chatFontSize
- @AppStorage(\.chatCodeFont) var chatCodeFont
@Environment(\.colorScheme) var colorScheme
+
+ static let defaultForegroundColor: Color = .primary
+
+ @StateObject private var fontScaleManager = FontScaleManager.shared
+
+ let foregroundColor: Color
+
+ var fontScale: Double {
+ fontScaleManager.currentScale
+ }
+
+ var scaledChatCodeFont: NSFont {
+ .monospacedSystemFont(ofSize: 12 * fontScale, weight: .regular)
+ }
+
+ var scaledChatFontSize: CGFloat {
+ chatFontSize * fontScale
+ }
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) {
@@ -40,14 +59,16 @@ public struct ThemedMarkdownText: View {
self.context = .init(onInsert: { content in
chat.send(.insertCode(content))
})
+ self.foregroundColor = Self.defaultForegroundColor
}
public var body: some View {
Markdown(text)
.textSelection(.enabled)
.markdownTheme(.custom(
- fontSize: chatFontSize,
- codeFont: chatCodeFont.value.nsFont,
+ fontSize: scaledChatFontSize,
+ foregroundColor: foregroundColor,
+ codeFont: scaledChatCodeFont,
codeBlockBackgroundColor: {
if syncCodeHighlightTheme {
if colorScheme == .light, let color = codeBackgroundColorLight.value {
@@ -81,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)
}
@@ -126,6 +148,8 @@ struct MarkdownCodeBlockView: View {
labelColor: codeBlockLabelColor,
context: context
)
+ // Force recreation when font size changes
+ .id("code-block-\(codeFont.pointSize)")
} else {
ScrollView(.horizontal) {
AsyncCodeBlockView(
@@ -142,22 +166,22 @@ struct MarkdownCodeBlockView: View {
labelColor: codeBlockLabelColor,
context: context
)
+ // Force recreation when font size changes
+ .id("code-block-\(codeFont.pointSize)")
}
}
}
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/UserMessage.swift b/Core/Sources/ConversationTab/Views/UserMessage.swift
index e7dc2d32..4b8a22e3 100644
--- a/Core/Sources/ConversationTab/Views/UserMessage.swift
+++ b/Core/Sources/ConversationTab/Views/UserMessage.swift
@@ -9,6 +9,7 @@ import Cache
import ChatTab
import ConversationServiceProvider
import SwiftUIFlowLayout
+import ChatAPIService
private let MAX_TEXT_LENGTH = 10000 // Maximum characters to prevent crashes
@@ -18,26 +19,10 @@ struct UserMessage: View {
let text: String
let imageReferences: [ImageReference]
let chat: StoreOf
+ let editorCornerRadius: Double
+ let requestType: RequestType
@Environment(\.colorScheme) var colorScheme
- @ObservedObject private var statusObserver = StatusObserver.shared
-
- struct AvatarView: View {
- @ObservedObject private var avatarViewModel = AvatarViewModel.shared
-
- var body: some View {
- if let avatarImage = avatarViewModel.avatarImage {
- avatarImage
- .resizable()
- .aspectRatio(contentMode: .fill)
- .frame(width: 24, height: 24)
- .clipShape(Circle())
- } else {
- Image(systemName: "person.circle")
- .resizable()
- .frame(width: 24, height: 24)
- }
- }
- }
+ @State var isMessageHovering: Bool = false
// Truncate the displayed user message if it's too long.
private var displayText: String {
@@ -46,31 +31,87 @@ struct UserMessage: View {
}
return text
}
+
+ private var isEditing: Bool {
+ if case .editUserMessage(let editId) = chat.state.editorMode {
+ return editId == id
+ }
+ return false
+ }
+
+ private var editorMode: Chat.EditorMode { .editUserMessage(id) }
+
+ private var isConversationMessage: Bool { requestType == .conversation }
var body: some View {
+ if !isEditing {
+ messageView
+ } else {
+ MessageInputArea(editorMode: editorMode, chat: chat, editorCornerRadius: editorCornerRadius)
+ }
+ }
+
+ var messageView: some View {
HStack {
VStack(alignment: .leading, spacing: 8) {
- HStack(spacing: 4) {
- AvatarView()
-
- Text(statusObserver.authStatus.username ?? "")
- .chatMessageHeaderTextStyle()
- .padding(2)
-
- Spacer()
- }
-
- ThemedMarkdownText(text: displayText, chat: chat)
- .frame(maxWidth: .infinity, alignment: .leading)
+ textView
+ .scaledPadding(.vertical, 8)
+ .scaledPadding(.horizontal, 10)
+ .background(
+ RoundedRectangle(cornerRadius: r)
+ .fill(isMessageHovering ? Color("DarkBlue") : Color("LightBlue"))
+ )
+ .overlay(
+ Group {
+ if isConversationMessage {
+ Color.clear
+ .contentShape(Rectangle())
+ .onTapGesture {
+ chat.send(.setEditorMode(.editUserMessage(id)))
+ }
+ .allowsHitTesting(true)
+ }
+ }
+ )
+ .onHover { isHovered in
+ if isConversationMessage {
+ isMessageHovering = isHovered
+ if isHovered {
+ NSCursor.pointingHand.push()
+ } else {
+ NSCursor.pop()
+ }
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .trailing)
if !imageReferences.isEmpty {
FlowLayout(mode: .scrollable, items: imageReferences, itemSpacing: 4) { item in
ImageReferenceItemView(item: item)
}
+ .environment(\.layoutDirection, .rightToLeft)
}
}
}
- .shadow(color: .black.opacity(0.05), radius: 6)
+ }
+
+ var textView: some View {
+ ThemedMarkdownText(text: displayText, chat: chat)
+ }
+}
+
+private struct MessageInputArea: View {
+ let editorMode: Chat.EditorMode
+ let chat: StoreOf
+ let editorCornerRadius: Double
+
+ var body: some View {
+ ChatPanelInputArea(
+ chat: chat,
+ r: editorCornerRadius,
+ editorMode: editorMode
+ )
+ .frame(maxWidth: .infinity)
}
}
@@ -96,7 +137,9 @@ struct UserMessage_Previews: PreviewProvider {
chat: .init(
initialState: .init(history: [] as [DisplayedChatMessage], isReceivingMessage: false),
reducer: { Chat(service: ChatService.service(for: chatTabInfo)) }
- )
+ ),
+ editorCornerRadius: 4,
+ requestType: .conversation
)
.padding()
.fixedSize(horizontal: true, vertical: true)
diff --git a/Core/Sources/ConversationTab/Views/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 677c44dc..f572454b 100644
--- a/Core/Sources/ConversationTab/Views/WorkingSetView.swift
+++ b/Core/Sources/ConversationTab/Views/WorkingSetView.swift
@@ -7,30 +7,33 @@ import JSONRPC
import SharedUIComponents
import OrderedCollections
import ConversationServiceProvider
+import ChatAPIService
struct WorkingSetView: View {
let chat: StoreOf
private let r: Double = 8
+ @State private var isExpanded: Bool = false
+
var body: some View {
WithPerceptionTracking {
VStack(spacing: 4) {
- WorkingSetHeader(chat: chat)
- .frame(height: 24)
- .padding(.leading, 12)
- .padding(.trailing, 5)
+ WorkingSetHeader(chat: chat, isExpanded: $isExpanded)
+ .scaledPadding(.vertical, 2)
+ .scaledPadding(.leading, 7)
- VStack(spacing: 0) {
- ForEach(chat.fileEditMap.elements, id: \.key.path) { element in
- FileEditView(chat: chat, fileEdit: element.value)
+ if isExpanded {
+ VStack(spacing: 0) {
+ ForEach(chat.fileEditMap.elements, id: \.key.path) { element in
+ FileEditView(chat: chat, fileEdit: element.value)
+ }
}
}
- .padding(.horizontal, 5)
}
- .padding(.top, 8)
- .padding(.bottom, 10)
+ .scaledPadding(.horizontal, 5)
+ .scaledPadding(.vertical, 4)
.frame(maxWidth: .infinity)
.background(
RoundedCorners(tl: r, tr: r, bl: 0, br: 0)
@@ -46,6 +49,7 @@ struct WorkingSetView: View {
struct WorkingSetHeader: View {
let chat: StoreOf
+ @Binding var isExpanded: Bool
@Environment(\.colorScheme) var colorScheme
@@ -58,37 +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)
- .foregroundColor(textForegroundColor)
- .padding(.horizontal, 6)
- .padding(.vertical, 2)
- .background(textBackgroundColor)
- .cornerRadius(2)
- .overlay(
- RoundedRectangle(cornerRadius: 2)
- .stroke(Color.white.opacity(0.07), lineWidth: 1)
- )
- .frame(width: 60, height: 15, alignment: .center)
+ .scaledFont(size: 11)
}
- .buttonStyle(PlainButtonStyle())
+ .buttonStyle(buttonStyle)
}
var body: some View {
WithPerceptionTracking {
HStack(spacing: 0) {
- Text(getTitle())
- .foregroundColor(.secondary)
- .font(.system(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",
@@ -100,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")
@@ -138,17 +156,17 @@ struct FileEditView: View {
switch imageType {
case .system(let name):
Image(systemName: name)
- .font(.system(size: 16, weight: .regular))
+ .scaledFont(size: 15, weight: .regular)
case .asset(let name):
Image(name)
.renderingMode(.template)
.resizable()
.aspectRatio(contentMode: .fit)
- .frame(height: 16)
+ .scaledFrame(height: 16)
}
}
.foregroundColor(.white)
- .frame(width: 22)
+ .scaledFrame(width: 22)
.frame(maxHeight: .infinity)
}
.buttonStyle(HoverButtonStyle(padding: 0, hoverColor: .white.opacity(0.2)))
@@ -188,15 +206,14 @@ struct FileEditView: View {
var body: some View {
HStack(spacing: 0) {
- HStack(spacing: 4) {
+ HStack(alignment: .center, spacing: 4) {
drawFileIcon(fileEdit.fileURL)
- .resizable()
.scaledToFit()
- .frame(width: 16, height: 16)
+ .scaledFrame(width: 16, height: 16)
.foregroundColor(.secondary)
Text(fileEdit.fileURL.lastPathComponent)
- .font(.system(size: 13))
+ .scaledFont(size: 13)
.foregroundColor(isHovering ? .white : Color("WorkingSetItemColor"))
}
@@ -210,8 +227,8 @@ struct FileEditView: View {
.onHover { hovering in
isHovering = hovering
}
- .padding(.leading, 7)
- .frame(height: 24)
+ .scaledPadding(.leading, 7)
+ .scaledFrame(height: 24)
.hoverRadiusBackground(
isHovered: isHovering,
hoverColor: Color.blue,
diff --git a/Core/Sources/ConversationTab/VisionViews/HoverableImageView.swift b/Core/Sources/ConversationTab/VisionViews/HoverableImageView.swift
index 56383d34..42ec5eb5 100644
--- a/Core/Sources/ConversationTab/VisionViews/HoverableImageView.swift
+++ b/Core/Sources/ConversationTab/VisionViews/HoverableImageView.swift
@@ -3,6 +3,7 @@ import ComposableArchitecture
import Persist
import ConversationServiceProvider
import GitHubCopilotService
+import SharedUIComponents
public struct HoverableImageView: View {
@Environment(\.colorScheme) var colorScheme
@@ -53,7 +54,7 @@ public struct HoverableImageView: View {
}) {
Image(systemName: "xmark")
.foregroundColor(.primary)
- .font(.system(size: 13))
+ .scaledFont(.system(size: 13))
.frame(width: 24, height: 24)
.background(
RoundedRectangle(cornerRadius: hoverableImageCornerRadius)
diff --git a/Core/Sources/ConversationTab/VisionViews/ImagesScrollView.swift b/Core/Sources/ConversationTab/VisionViews/ImagesScrollView.swift
index 87e7179a..c2e3d6b8 100644
--- a/Core/Sources/ConversationTab/VisionViews/ImagesScrollView.swift
+++ b/Core/Sources/ConversationTab/VisionViews/ImagesScrollView.swift
@@ -3,9 +3,10 @@ import ComposableArchitecture
public struct ImagesScrollView: View {
let chat: StoreOf
+ let editorMode: Chat.EditorMode
public var body: some View {
- let attachedImages = chat.state.attachedImages.reversed()
+ let attachedImages = chat.state.getChatContext(of: editorMode).attachedImages.reversed()
return ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 2) {
ForEach(attachedImages, id: \.self) { image in
@@ -13,7 +14,5 @@ public struct ImagesScrollView: View {
}
}
}
- .padding(.horizontal, 8)
- .padding(.top, 8)
}
}
diff --git a/Core/Sources/ConversationTab/VisionViews/VisionMenuView.swift b/Core/Sources/ConversationTab/VisionViews/VisionMenuView.swift
index 8e18d40d..ca12c71a 100644
--- a/Core/Sources/ConversationTab/VisionViews/VisionMenuView.swift
+++ b/Core/Sources/ConversationTab/VisionViews/VisionMenuView.swift
@@ -91,24 +91,27 @@ public struct VisionMenuView: View {
Image(systemName: "macwindow")
Text("Capture Window")
}
+ .scaledFont(.body)
Button(action: { runScreenCapture(args: ["-s", "-c"]) }) {
Image(systemName: "macwindow.and.cursorarrow")
Text("Capture Selection")
}
+ .scaledFont(.body)
Button(action: { showImagePicker() }) {
Image(systemName: "photo")
Text("Attach File")
}
+ .scaledFont(.body)
} label: {
Image(systemName: "photo.badge.plus")
.resizable()
.aspectRatio(contentMode: .fill)
- .frame(width: 16, height: 16)
- .padding(4)
+ .scaledFrame(width: 16, height: 16)
+ .scaledPadding(4)
.foregroundColor(.primary.opacity(0.85))
- .font(Font.system(size: 11, weight: .semibold))
+ .scaledFont(size: 11, weight: .semibold)
}
.buttonStyle(HoverButtonStyle(padding: 0))
.help("Attach images")
@@ -122,9 +125,13 @@ public struct VisionMenuView: View {
action: {
NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_ScreenCapture")!)
}).keyboardShortcut(.defaultAction)
+ .scaledFont(.body)
+
Button("Deny", role: .cancel, action: {})
+ .scaledFont(.body)
} message: {
Text("Grant access to this application in Privacy & Security settings, located in System Settings")
+ .scaledFont(.body)
}
}
}
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 cd441b84..a8910979 100644
--- a/Core/Sources/HostApp/AdvancedSettings/ChatSection.swift
+++ b/Core/Sources/HostApp/AdvancedSettings/ChatSection.swift
@@ -1,43 +1,106 @@
+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
+ @AppStorage(\.enableSubagent) var enableSubagent
+ @ObservedObject private var featureFlags = FeatureFlagManager.shared
+ @ObservedObject private var copilotPolicy = CopilotPolicyManager.shared
var body: some View {
SettingsSection(title: "Chat Settings") {
+ // Copilot instructions - .github/copilot-instructions.md
+ CopilotInstructionSetting()
+ .padding(SettingsToggle.defaultPadding)
+
+ Divider()
+
+ // Custom Instructions - .github/instructions/*.instructions.md
+ PromptFileSetting(promptType: .instructions)
+ .padding(SettingsToggle.defaultPadding)
+
+ Divider()
+
+ 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
SettingsToggle(
title: "Auto-attach Chat Window to Xcode",
isOn: $autoAttachChatToXcode
)
+ Divider()
+
+ // Fix error toggle
+ SettingsToggle(
+ title: "Quick fix for error",
+ isOn: $enableFixError
+ )
+
Divider()
// Response language picker
ResponseLanguageSetting()
.padding(SettingsToggle.defaultPadding)
-
+
Divider()
-
- // Copilot instructions - .github/copilot-instructions.md
- CopilotInstructionSetting()
+
+ // Font Size
+ FontSizeSetting()
.padding(SettingsToggle.defaultPadding)
-
+
Divider()
+
+ if featureFlags.isAgentModeEnabled {
+ // Agent Max Tool Calling Requests
+ AgentMaxToolCallLoopSetting()
+ .padding(SettingsToggle.defaultPadding)
- // Custom Instructions - .github/instructions/*.instructions.md
- PromptFileSetting(promptType: .instructions)
- .padding(SettingsToggle.defaultPadding)
-
- Divider()
+ Divider()
+ }
- // Custom Prompts - .github/prompts/*.prompt.md
- PromptFileSetting(promptType: .prompt)
- .padding(SettingsToggle.defaultPadding)
+ // Auto Compress
+ AgentAutoCompressSetting()
}
}
}
@@ -96,10 +159,207 @@ struct ResponseLanguageSetting: View {
Text(option.displayName).tag(option.localeCode)
}
}
- .frame(maxWidth: 200, alignment: .leading)
+ .frame(maxWidth: 200, alignment: .trailing)
+ }
+ }
+ }
+}
+
+struct FontSizeSetting: View {
+ static let defaultSliderThumbRadius: CGFloat = Font.body.builtinSize
+
+ @AppStorage(\.chatFontSize) var chatFontSize
+ @ScaledMetric(relativeTo: .body) var scaledPadding: CGFloat = 100
+
+ @State private var sliderValue: Double = 0
+ @State private var textWidth: CGFloat = 0
+ @State private var sliderWidth: CGFloat = 0
+
+ @StateObject private var fontScaleManager: FontScaleManager = .shared
+
+ var maxSliderValue: Double {
+ FontScaleManager.maxScale * 100
+ }
+
+ var minSliderValue: Double {
+ FontScaleManager.minScale * 100
+ }
+
+ var defaultSliderValue: Double {
+ FontScaleManager.defaultScale * 100
+ }
+
+ var sliderFontSize: Double {
+ chatFontSize * sliderValue / 100
+ }
+
+ var maxScaleFontSize: Double {
+ FontScaleManager.maxScale * chatFontSize
+ }
+
+ var body: some View {
+ WithPerceptionTracking {
+ HStack {
+ VStack(alignment: .leading) {
+ Text("Font Size")
+ .font(.body)
+ Text("Use the slider to set the preferred size.")
+ .font(.footnote)
+ }
+
+ Spacer()
+
+ VStack(alignment: .leading, spacing: 0) {
+ HStack(alignment: .center, spacing: 8) {
+ Text("A")
+ .font(.system(size: sliderFontSize))
+ .frame(width: maxScaleFontSize)
+
+ Slider(value: $sliderValue, in: minSliderValue...maxSliderValue, step: 10) { _ in
+ fontScaleManager.setFontScale(sliderValue / 100)
+ }
+ .background(
+ GeometryReader { geometry in
+ Color.clear
+ .onAppear {
+ sliderWidth = geometry.size.width
+ }
+ }
+ )
+
+ Text("\(Int(sliderValue))%")
+ .font(.body)
+ .foregroundColor(.primary)
+ .frame(width: 40, alignment: .center)
+ }
+ .frame(height: maxScaleFontSize)
+
+ Text("Default")
+ .font(.caption)
+ .foregroundColor(.primary)
+ .background(
+ GeometryReader { geometry in
+ Color.clear
+ .onAppear {
+ textWidth = geometry.size.width
+ }
+ }
+ )
+ .padding(.leading, calculateDefaultMarkerXPosition() + 6)
+ .onHover {
+ if $0 {
+ NSCursor.pointingHand.push()
+ } else {
+ NSCursor.pop()
+ }
+ }
+ .onTapGesture {
+ fontScaleManager.resetFontScale()
+ }
+ }
+ .frame(width: 350, height: 35)
+ }
+ .onAppear {
+ sliderValue = fontScaleManager.currentScale * 100
+ }
+ .onChange(of: fontScaleManager.currentScale) {
+ // Use rounded value for floating-point precision issue
+ sliderValue = round($0 * 10) / 10 * 100
}
}
}
+
+ private func calculateDefaultMarkerXPosition() -> CGFloat {
+ let sliderRange = maxSliderValue - minSliderValue
+ let normalizedPosition = (defaultSliderValue - minSliderValue) / sliderRange
+
+ let usableWidth = sliderWidth - (Self.defaultSliderThumbRadius * 2)
+
+ let markerPosition = Self.defaultSliderThumbRadius + (CGFloat(normalizedPosition) * usableWidth)
+
+ return markerPosition - textWidth / 2 + maxScaleFontSize
+ }
+}
+
+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 {
@@ -193,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)
+ }
)
}
}
@@ -219,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/BYOKConfigView.swift b/Core/Sources/HostApp/BYOKConfigView.swift
index a55b995a..50d569da 100644
--- a/Core/Sources/HostApp/BYOKConfigView.swift
+++ b/Core/Sources/HostApp/BYOKConfigView.swift
@@ -22,7 +22,7 @@ public struct BYOKConfigView: View {
private func expansionBinding(for provider: BYOKProvider) -> Binding {
Binding(
- get: { expansionStates[provider] ?? true },
+ get: { expansionStates[provider] ?? false },
set: { expansionStates[provider] = $0 }
)
}
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 5ea63b54..194c4f91 100644
--- a/Core/Sources/HostApp/BYOKSettings/ProviderConfigView.swift
+++ b/Core/Sources/HostApp/BYOKSettings/ProviderConfigView.swift
@@ -83,93 +83,66 @@ 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
private var ProviderLabelView: some View {
- HStack(spacing: 8) {
- Image(systemName: "chevron.right").font(.footnote.bold())
- .foregroundColor(.secondary)
- .rotationEffect(.degrees(isExpanded ? 90 : 0))
- .animation(.easeInOut(duration: 0.3), value: isExpanded)
- .buttonStyle(.borderless)
- .opacity(hasApiKey ? 1 : 0)
- .allowsHitTesting(hasApiKey)
-
- HStack(spacing: 8) {
- Text(provider.title)
- .foregroundColor(
- hasApiKey ? .primary : Color(
- nsColor: colorScheme == .light ? .tertiaryLabelColor : .secondaryLabelColor
- )
- )
- .bold() +
- Text(hasModels ? " (\(allModels.filter { $0.isRegistered }.count) of \(allModels.count) Enabled)" : "")
- .foregroundColor(.primary)
- }
- .padding(.vertical, 4)
- }
+ Text(provider.title)
+ .foregroundColor(
+ hasApiKey ? .primary : Color(
+ nsColor: colorScheme == .light ? .tertiaryLabelColor : .secondaryLabelColor
+ )
+ )
+ .bold() +
+ Text(hasModels ? " (\(allModels.filter { $0.isRegistered }.count) of \(allModels.count) Enabled)" : "")
+ .foregroundColor(.primary)
}
private var ProviderHeaderRowView: some View {
- HStack(alignment: .center, spacing: 16) {
- ProviderLabelView
-
- Spacer()
-
- if let errorMessage = errorMessage {
- Badge(text: "Can't connect. Check your API key or network.", level: .danger, icon: "xmark.circle.fill")
- .help("Unable to connect to \(provider.title). \(errorMessage) Refresh or recheck your key setup.")
- }
-
- if hasApiKey {
- if dataManager.isLoadingProvider(provider) {
- ProgressView().controlSize(.small)
- } else {
- ConfiguredProviderActions
+ DisclosureSettingsRow(
+ isExpanded: $isExpanded,
+ isEnabled: hasApiKey,
+ accessibilityLabel: { expanded in "\(provider.title) \(expanded ? "collapse" : "expand")" },
+ onToggle: { wasExpanded, nowExpanded in
+ if wasExpanded && !nowExpanded && isSearchBarVisible {
+ searchText = ""
+ withAnimation(.easeInOut) { isSearchBarVisible = false }
}
- } else {
- UnconfiguredProviderAction
- }
- }
- .padding(.leading, 20)
- .padding(.trailing, 24)
- .padding(.vertical, 8)
- .background(QuaternarySystemFillColor.opacity(0.75))
- .contentShape(Rectangle())
- .onTapGesture {
- guard hasApiKey else { return }
- let wasExpanded = isExpanded
- withAnimation(.easeInOut) {
- isExpanded.toggle()
- }
- // If we just collapsed, and the search bar was open, reset it.
- if wasExpanded && !isExpanded && isSearchBarVisible {
- searchText = ""
- withAnimation(.easeInOut) {
- isSearchBarVisible = false
+ },
+ title: { ProviderLabelView },
+ actions: {
+ Group {
+ if let errorMessage = errorMessage {
+ Badge(
+ text: "Can't connect. Check your API key or network.",
+ level: .danger,
+ icon: "xmark.circle.fill"
+ )
+ .help("Unable to connect to \(provider.title). \(errorMessage) Refresh or recheck your key setup.")
+ }
+ if hasApiKey {
+ if dataManager.isLoadingProvider(provider) {
+ ProgressView().controlSize(.small)
+ } else {
+ ConfiguredProviderActions
+ }
+ } else {
+ UnconfiguredProviderAction
+ }
}
+ .padding(.trailing, 4)
+ .frame(height: 30)
}
- }
- .accessibilityAddTraits(.isButton)
- .accessibilityLabel("\(provider.title) \(isExpanded ? "collapse" : "expand")")
+ )
}
@ViewBuilder
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 4b22edbb..ba3c3da7 100644
--- a/Core/Sources/HostApp/HostApp.swift
+++ b/Core/Sources/HostApp/HostApp.swift
@@ -10,14 +10,14 @@ extension KeyboardShortcuts.Name {
public enum TabIndex: Int, CaseIterable {
case general = 0
case advanced = 1
- case mcp = 2
+ case tools = 2
case byok = 3
var title: String {
switch self {
case .general: return "General"
case .advanced: return "Advanced"
- case .mcp: return "MCP"
+ case .tools: return "Tools"
case .byok: return "Models"
}
}
@@ -26,7 +26,7 @@ public enum TabIndex: Int, CaseIterable {
switch self {
case .general: return "CopilotLogo"
case .advanced: return "gearshape.2.fill"
- case .mcp: return "wrench.and.screwdriver.fill"
+ case .tools: return "wrench.and.screwdriver.fill"
case .byok: return "Model"
}
}
@@ -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/MCPConfigView.swift b/Core/Sources/HostApp/MCPConfigView.swift
deleted file mode 100644
index df80423a..00000000
--- a/Core/Sources/HostApp/MCPConfigView.swift
+++ /dev/null
@@ -1,207 +0,0 @@
-import Client
-import Foundation
-import Logger
-import SharedUIComponents
-import SwiftUI
-import Toast
-import ConversationServiceProvider
-import GitHubCopilotService
-import ComposableArchitecture
-
-struct MCPConfigView: View {
- @State private var mcpConfig: String = ""
- @Environment(\.toast) var toast
- @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
- @Environment(\.colorScheme) var colorScheme
-
- private static var lastSyncTimestamp: Date? = nil
-
- var body: some View {
- WithPerceptionTracking {
- ScrollView {
- VStack(alignment: .leading, spacing: 8) {
- MCPIntroView(isMCPFFEnabled: $isMCPFFEnabled)
- if isMCPFFEnabled {
- MCPToolsListView()
- }
- }
- .padding(20)
- .onAppear {
- setupConfigFilePath()
- Task {
- await updateMCPFeatureFlag()
- }
- }
- .onDisappear {
- stopMonitoringConfigFile()
- }
- .onChange(of: isMCPFFEnabled) { newMCPFFEnabled in
- if newMCPFFEnabled {
- startMonitoringConfigFile()
- refreshConfiguration(())
- } else {
- stopMonitoringConfigFile()
- }
- }
- .onReceive(DistributedNotificationCenter.default()
- .publisher(for: .gitHubCopilotFeatureFlagsDidChange)) { _ in
- Task {
- await updateMCPFeatureFlag()
- }
- }
- }
- }
- }
-
- 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
-
- if !fileManager.fileExists(atPath: configDirectory.path) {
- try? fileManager.createDirectory(at: configDirectory, withIntermediateDirectories: true)
- }
-
- // If the file doesn't exist, create one with a proper structure
- let configFileURL = URL(fileURLWithPath: configFilePath)
- if !fileManager.fileExists(atPath: configFilePath) {
- try? """
- {
- "servers": {
-
- }
- }
- """.write(to: configFileURL, atomically: true, encoding: .utf8)
- }
-
- // Read the current content from file and ensure it's valid JSON
- mcpConfig = readAndValidateJSON(from: configFileURL) ?? "{}"
-
- // Get initial modification date
- lastModificationDate = getFileModificationDate(url: configFileURL)
- }
-
- /// Reads file content and validates it as JSON, returning only the "servers" object
- private func readAndValidateJSON(from url: URL) -> String? {
- guard let data = try? Data(contentsOf: url) else {
- return nil
- }
-
- // Try to parse as JSON to validate
- do {
- // First verify it's valid JSON
- let jsonObject = try JSONSerialization.jsonObject(with: data) as? [String: Any]
-
- // Extract the "servers" object
- guard let servers = jsonObject?["servers"] as? [String: Any] else {
- Logger.client.info("No 'servers' key found in MCP configuration")
- toast("No 'servers' key found in MCP configuration", .error)
- // Return empty object if no servers section
- return "{}"
- }
-
- // Convert the servers object back to JSON data
- let serversData = try JSONSerialization.data(
- withJSONObject: servers, options: [.prettyPrinted])
-
- // Return as a string
- return String(data: serversData, encoding: .utf8)
- } catch {
- // If parsing fails, return nil
- Logger.client.info("Parsing MCP JSON error: \(error)")
- toast("Invalid JSON in MCP configuration file", .error)
- return nil
- }
- }
-
- private func getFileModificationDate(url: URL) -> Date? {
- let attributes = try? FileManager.default.attributesOfItem(atPath: url.path)
- return attributes?[.modificationDate] as? Date
- }
-
- private func startMonitoringConfigFile() {
- stopMonitoringConfigFile() // Stop existing monitoring if any
-
- isMonitoring = true
-
- 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
-
- let currentDate = getFileModificationDate(url: configFileURL)
-
- if let currentDate = currentDate, currentDate != lastModificationDate {
- // File modification date has changed, update our record
- lastModificationDate = currentDate
-
- // Read and validate the updated content
- if let validJson = readAndValidateJSON(from: configFileURL) {
- await MainActor.run {
- mcpConfig = validJson
- refreshConfiguration(validJson)
- toast("MCP configuration file updated", .info)
- }
- } else {
- // If JSON is invalid, show error
- await MainActor.run {
- toast("Invalid JSON in MCP configuration file", .error)
- }
- }
- }
- }
- }
- }
-
- private func stopMonitoringConfigFile() {
- isMonitoring = false
- fileMonitorTask?.cancel()
- fileMonitorTask = nil
- }
-
- func refreshConfiguration(_: Any) {
- if MCPConfigView.lastSyncTimestamp == lastModificationDate {
- return
- }
-
- MCPConfigView.lastSyncTimestamp = lastModificationDate
-
- let fileURL = URL(fileURLWithPath: configFilePath)
- if let jsonString = readAndValidateJSON(from: fileURL) {
- UserDefaults.shared.set(jsonString, for: \.gitHubCopilotMCPConfig)
- }
-
- 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)
- }
- }
- }
-}
-
-#Preview {
- MCPConfigView()
- .frame(width: 800, height: 600)
-}
diff --git a/Core/Sources/HostApp/MCPSettings/MCPAppState.swift b/Core/Sources/HostApp/MCPSettings/MCPAppState.swift
deleted file mode 100644
index f6d16d98..00000000
--- a/Core/Sources/HostApp/MCPSettings/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..) {
- self.isExpanded = isExpanded
- self._isMCPFFEnabled = isMCPFFEnabled
- }
-
- var body: some View {
- VStack(alignment: .leading, spacing: 8) {
- if !isMCPFFEnabled {
- GroupBox {
- HStack(alignment: .top, spacing: 8) {
- Image(systemName: "info.circle.fill")
- .font(.body)
- .foregroundColor(.gray)
- Text(
- "MCP servers are disabled by your organizationโs policy. To enable them, 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)
- )
- )
- }
-
- GroupBox(
- label: Text("Model Context Protocol (MCP) Configuration")
- .fontWeight(.bold)
- ) {
- Text(
- "MCP is an open standard that connects AI models to external tools. In Xcode, it enhances GitHub Copilot's agent mode by connecting to any MCP server and integrating its tools into your workflow. [Learn More](https://modelcontextprotocol.io/introduction)"
- )
- }.groupBoxStyle(CardGroupBoxStyle())
-
- if isMCPFFEnabled {
- DisclosureGroup(isExpanded: $isExpanded) {
- exampleConfigView()
- } label: {
- sectionHeader()
- }
- .padding(.horizontal, 0)
- .padding(.vertical, 10)
-
- HStack(spacing: 8) {
- Button {
- openConfigFile()
- } label: {
- HStack(spacing: 0) {
- Image(systemName: "square.and.pencil")
- .resizable()
- .aspectRatio(contentMode: .fit)
- .frame(width: 12, height: 12, alignment: .center)
- .padding(4)
- Text("Edit Config")
- }
- .conditionalFontWeight(.semibold)
- }
- .buttonStyle(.borderedProminent)
- .help("Configure your MCP server")
-
- Button {
- openMCPRunTimeLogFolder()
- } label: {
- HStack(spacing: 0) {
- Image(systemName: "folder")
- .resizable()
- .aspectRatio(contentMode: .fit)
- .frame(width: 12, height: 12, alignment: .center)
- .padding(4)
- Text("Open MCP Log Folder")
- }
- .conditionalFontWeight(.semibold)
- }
- .buttonStyle(.bordered)
- .help("Open MCP Runtime Log Folder")
- }
- }
- }
-
- }
-
- @ViewBuilder
- private func exampleConfigView() -> some View {
- Text(exampleConfig)
- .font(.system(.body, design: .monospaced))
- .padding(.horizontal, 16)
- .padding(.top, 8)
- .padding(.bottom, 6)
- .frame(maxWidth: .infinity, alignment: .leading)
- .background(
- Color(nsColor: .textBackgroundColor).opacity(0.5)
- )
- .textSelection(.enabled)
- .cornerRadius(4)
- .overlay(
- RoundedRectangle(cornerRadius: 4)
- .inset(by: 0.5)
- .stroke(Color("GroupBoxStrokeColor"), lineWidth: 1)
- )
- }
-
- @ViewBuilder
- private func sectionHeader() -> some View {
- HStack(spacing: 8) {
- Text("Example Configuration").foregroundColor(.primary.opacity(0.85))
-
- CopyButton(
- copy: {
- NSPasteboard.general.clearContents()
- NSPasteboard.general.setString(exampleConfig, forType: .string)
- },
- foregroundColor: .primary.opacity(0.85),
- fontWeight: .semibold
- )
- .frame(width: 10, height: 10)
- }
- .padding(.leading, 4)
- }
-
- private func openConfigFile() {
- let url = URL(fileURLWithPath: mcpConfigFilePath)
- NSWorkspace.shared.open(url)
- }
-
- private func openMCPRunTimeLogFolder() {
- let url = URL(
- fileURLWithPath: FileLoggingLocation.mcpRuntimeLogsPath.description,
- isDirectory: true
- )
-
- // Create directory if it doesn't exist
- if !FileManager.default.fileExists(atPath: url.path) {
- do {
- try FileManager.default.createDirectory(
- atPath: url.path,
- withIntermediateDirectories: true,
- attributes: nil
- )
- } catch {
- Logger.client.error("Failed to create MCP runtime log folder: \(error)")
- return
- }
- }
-
- NSWorkspace.shared.open(url)
- }
-}
-
-#Preview {
- MCPIntroView(isExpanded: true, isMCPFFEnabled: .constant(true))
- .frame(width: 800)
-}
-
-#Preview {
- MCPIntroView(isExpanded: true, isMCPFFEnabled: .constant(false))
- .frame(width: 800)
-}
diff --git a/Core/Sources/HostApp/MCPSettings/MCPServerToolsSection.swift b/Core/Sources/HostApp/MCPSettings/MCPServerToolsSection.swift
deleted file mode 100644
index 9641a45a..00000000
--- a/Core/Sources/HostApp/MCPSettings/MCPServerToolsSection.swift
+++ /dev/null
@@ -1,199 +0,0 @@
-import SwiftUI
-import Persist
-import GitHubCopilotService
-import Client
-import Logger
-
-/// Section for a single server's tools
-struct MCPServerToolsSection: View {
- let serverTools: MCPServerToolsCollection
- @Binding var isServerEnabled: Bool
- var forceExpand: Bool = false
- @State private var toolEnabledStates: [String: Bool] = [:]
- @State private var isExpanded: Bool = true
- private var originalServerName: String { serverTools.name }
-
- private var serverToggleLabel: some View {
- HStack(spacing: 8) {
- Text("MCP Server: \(serverTools.name)").fontWeight(.medium)
- if serverTools.status == .error {
- let message = extractErrorMessage(serverTools.error?.description ?? "")
- Badge(text: message, level: .danger, icon: "xmark.circle.fill")
- }
- Spacer()
- }
- }
-
- private var serverToggle: some View {
- Toggle(isOn: Binding(
- get: { isServerEnabled },
- set: { updateAllToolsStatus(enabled: $0) }
- )) {
- serverToggleLabel
- }
- .toggleStyle(.checkbox)
- .padding(.leading, 4)
- .disabled(serverTools.status == .error)
- }
-
- private var divider: some View {
- Divider()
- .padding(.leading, 36)
- .padding(.top, 2)
- .padding(.bottom, 4)
- }
-
- private var toolsList: some View {
- VStack(spacing: 0) {
- divider
- ForEach(serverTools.tools, id: \.name) { tool in
- MCPToolRow(
- tool: tool,
- isServerEnabled: isServerEnabled,
- isToolEnabled: toolBindingFor(tool),
- onToolToggleChanged: { handleToolToggleChange(tool: tool, isEnabled: $0) }
- )
- }
- }
- .onChange(of: serverTools) { newValue in
- initializeToolStates(server: newValue)
- }
- }
-
-
- var body: some View {
- VStack(alignment: .leading, spacing: 0) {
- // Conditional view rendering based on error state
- if serverTools.status == .error {
- // No disclosure group for error state
- VStack(spacing: 0) {
- serverToggle.padding(.leading, 12)
- divider.padding(.top, 4)
- }
- } else {
- // Regular DisclosureGroup for non-error state
- DisclosureGroup(isExpanded: $isExpanded) {
- toolsList
- } label: {
- serverToggle
- }
- .onAppear {
- initializeToolStates(server: serverTools)
- if forceExpand {
- isExpanded = true
- }
- }
- .onChange(of: forceExpand) { newForceExpand in
- if newForceExpand {
- isExpanded = true
- }
- }
-
- if !isExpanded {
- divider
- }
- }
- }
- }
-
- private func extractErrorMessage(_ description: String) -> String {
- guard let messageRange = description.range(of: "message:"),
- let stackRange = description.range(of: "stack:") else {
- return description
- }
- let start = description.index(messageRange.upperBound, offsetBy: 0)
- let end = description.index(stackRange.lowerBound, offsetBy: 0)
- return description[start.. Binding {
- Binding(
- get: { toolEnabledStates[tool.name] ?? (tool._status == .enabled) },
- set: { toolEnabledStates[tool.name] = $0 }
- )
- }
-
- private func handleToolToggleChange(tool: MCPTool, isEnabled: Bool) {
- toolEnabledStates[tool.name] = isEnabled
-
- // Update server state based on tool states
- updateServerState()
-
- // Update only this specific tool status
- updateToolStatus(tool: tool, isEnabled: isEnabled)
- }
-
- private func updateServerState() {
- // If any tool is enabled, server should be enabled
- // If all tools are disabled, server should be disabled
- let allToolsDisabled = serverTools.tools.allSatisfy { tool in
- !(toolEnabledStates[tool.name] ?? (tool._status == .enabled))
- }
-
- isServerEnabled = !allToolsDisabled
- }
-
- private func updateToolStatus(tool: MCPTool, isEnabled: Bool) {
- let serverUpdate = UpdateMCPToolsStatusServerCollection(
- name: serverTools.name,
- tools: [UpdatedMCPToolsStatus(name: tool.name, status: isEnabled ? .enabled : .disabled)]
- )
-
- updateMCPStatus([serverUpdate])
- }
-
- private func updateAllToolsStatus(enabled: Bool) {
- isServerEnabled = enabled
-
- // Get all tools for this server from the original collection
- let allServerTools = CopilotMCPToolManagerObservable.shared.availableMCPServerTools
- .first(where: { $0.name == originalServerName })?.tools ?? serverTools.tools
-
- // Update all tool states - includes both visible and filtered-out tools
- for tool in allServerTools {
- toolEnabledStates[tool.name] = enabled
- }
-
- // Create status update for all tools
- let serverUpdate = UpdateMCPToolsStatusServerCollection(
- name: serverTools.name,
- tools: allServerTools.map {
- UpdatedMCPToolsStatus(name: $0.name, status: enabled ? .enabled : .disabled)
- }
- )
-
- updateMCPStatus([serverUpdate])
- }
-
- private func updateMCPStatus(_ serverUpdates: [UpdateMCPToolsStatusServerCollection]) {
- // Update status in AppState and CopilotMCPToolManager
- AppState.shared.updateMCPToolsStatus(serverUpdates)
-
- Task {
- do {
- let service = try getService()
- try await service.updateMCPServerToolsStatus(serverUpdates)
- } catch {
- Logger.client.error("Failed to update MCP status: \(error.localizedDescription)")
- }
- }
- }
-}
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 85205d04..7ab60d87 100644
--- a/Core/Sources/HostApp/SharedComponents/CardGroupBoxStyle.swift
+++ b/Core/Sources/HostApp/SharedComponents/CardGroupBoxStyle.swift
@@ -1,23 +1,30 @@
import SwiftUI
+import SharedUIComponents
public struct CardGroupBoxStyle: GroupBoxStyle {
public var backgroundColor: Color
- public init(backgroundColor: Color = Color("GroupBoxBackgroundColor")) {
+ public var borderColor: Color
+ public init(
+ backgroundColor: Color = QuaternarySystemFillColor.opacity(0.75),
+ borderColor: Color = SecondarySystemFillColor
+ ) {
self.backgroundColor = backgroundColor
+ self.borderColor = borderColor
}
public func makeBody(configuration: Configuration) -> some View {
VStack(alignment: .leading, spacing: 11) {
configuration.label.foregroundColor(.primary)
configuration.content.foregroundColor(.primary)
}
- .padding(8)
+ .padding(.vertical, 12)
+ .padding(.horizontal, 20)
.frame(maxWidth: .infinity, alignment: .topLeading)
.background(backgroundColor)
- .cornerRadius(4)
+ .cornerRadius(12)
.overlay(
- RoundedRectangle(cornerRadius: 4)
+ RoundedRectangle(cornerRadius: 12)
.inset(by: 0.5)
- .stroke(Color("GroupBoxStrokeColor"), lineWidth: 1)
+ .stroke(borderColor, lineWidth: 1)
)
}
}
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
new file mode 100644
index 00000000..6567985c
--- /dev/null
+++ b/Core/Sources/HostApp/SharedComponents/DisclosureSettingsRow.swift
@@ -0,0 +1,78 @@
+import SwiftUI
+import SharedUIComponents
+
+public struct DisclosureSettingsRow: View {
+ @Binding private var isExpanded: Bool
+ private let isEnabled: Bool
+ private let background: Color
+ private let padding: EdgeInsets
+ private let spacing: CGFloat
+ private let accessibilityLabel: (Bool) -> String
+ private let onToggle: ((Bool, Bool) -> Void)?
+ @ViewBuilder private let title: () -> Title
+ @ViewBuilder private let subtitle: () -> Subtitle
+ @ViewBuilder private let actions: () -> Actions
+
+ public init(
+ isExpanded: Binding,
+ isEnabled: Bool = true,
+ background: Color = QuaternarySystemFillColor.opacity(0.75),
+ padding: EdgeInsets = EdgeInsets(top: 8, leading: 20, bottom: 8, trailing: 20),
+ spacing: CGFloat = 16,
+ accessibilityLabel: @escaping (Bool) -> String = { expanded in expanded ? "collapse" : "expand" },
+ onToggle: ((Bool, Bool) -> Void)? = nil,
+ @ViewBuilder title: @escaping () -> Title,
+ @ViewBuilder subtitle: @escaping (() -> Subtitle) = { EmptyView() },
+ @ViewBuilder actions: @escaping () -> Actions = { EmptyView() }
+ ) {
+ _isExpanded = isExpanded
+ self.isEnabled = isEnabled
+ self.background = background
+ self.padding = padding
+ self.spacing = spacing
+ self.accessibilityLabel = accessibilityLabel
+ self.onToggle = onToggle
+ self.title = title
+ self.subtitle = subtitle
+ self.actions = actions
+ }
+
+ public var body: some View {
+ HStack(alignment: .center, spacing: spacing) {
+ VStack(alignment: .leading, spacing: 0) {
+ HStack(spacing: 8) {
+ Image(systemName: "chevron.right")
+ .font(.footnote.bold())
+ .foregroundColor(.secondary)
+ .rotationEffect(.degrees(isExpanded ? 90 : 0))
+ .animation(.easeInOut(duration: 0.3), value: isExpanded)
+ .opacity(isEnabled ? 1 : 0)
+ .allowsHitTesting(isEnabled)
+ title()
+ }
+ .padding(.vertical, 4)
+
+ subtitle()
+ .padding(.leading, 16)
+ .font(.subheadline)
+ .foregroundColor(.secondary)
+ }
+
+ Spacer()
+ actions()
+ }
+ .padding(padding)
+ .background(background)
+ .contentShape(Rectangle())
+ .onTapGesture {
+ guard isEnabled else { return }
+ let previous = isExpanded
+ withAnimation(.easeInOut) {
+ isExpanded.toggle()
+ }
+ onToggle?(previous, isExpanded)
+ }
+ .accessibilityAddTraits(.isButton)
+ .accessibilityLabel(accessibilityLabel(isExpanded))
+ }
+}
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 5c51d21f..a3dc805d 100644
--- a/Core/Sources/HostApp/SharedComponents/SettingsToggle.swift
+++ b/Core/Sources/HostApp/SharedComponents/SettingsToggle.swift
@@ -4,14 +4,38 @@ 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)
.toggleStyle(.switch)
+ .padding(.vertical, 4)
}
.padding(SettingsToggle.defaultPadding)
}
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 c9a4c55c..c4a372cd 100644
--- a/Core/Sources/HostApp/TabContainer.swift
+++ b/Core/Sources/HostApp/TabContainer.swift
@@ -15,8 +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
@Binding var tag: TabIndex
public init() {
@@ -36,19 +36,6 @@ public struct TabContainer: View {
set: { store.send(.setActiveTab($0)) }
)
}
-
- private func updateAgentModeFeatureFlag() async {
- do {
- let service = try getService()
- let featureFlags = try await service.getCopilotFeatureFlags()
- isAgentModeFFEnabled = featureFlags?.agentMode ?? true
- if hostAppStore.state.activeTabIndex == .mcp && !isAgentModeFFEnabled {
- hostAppStore.send(.setActiveTab(.general))
- }
- } catch {
- Logger.client.error("Failed to get copilot feature flags: \(error)")
- }
- }
public var body: some View {
WithPerceptionTracking {
@@ -58,17 +45,18 @@ public struct TabContainer: View {
ZStack(alignment: .center) {
GeneralView(store: store.scope(state: \.general, action: \.general)).tabBarItem(for: .general)
AdvancedSettings().tabBarItem(for: .advanced)
- if isAgentModeFFEnabled {
- MCPConfigView().tabBarItem(for: .mcp)
+ if featureFlags.isAgentModeEnabled {
+ MCPConfigView().tabBarItem(for: .tools)
+ }
+ if featureFlags.isBYOKEnabled {
+ BYOKConfigView().tabBarItem(for: .byok)
}
- BYOKConfigView().tabBarItem(for: .byok)
}
.environment(\.tabBarTabTag, tag)
.frame(minHeight: 400)
}
.focusable(false)
.padding(.top, 8)
- .background(.ultraThinMaterial.opacity(0.01))
.background(Color(nsColor: .controlBackgroundColor))
.handleToast()
.onPreferenceChange(TabBarItemPreferenceKey.self) { items in
@@ -76,16 +64,17 @@ public struct TabContainer: View {
}
.onAppear {
store.send(.appear)
- Task {
- await updateAgentModeFeatureFlag()
+ }
+ .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 updateAgentModeFeatureFlag()
- }
+ .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
new file mode 100644
index 00000000..6ece9ade
--- /dev/null
+++ b/Core/Sources/HostApp/ToolsConfigView.swift
@@ -0,0 +1,275 @@
+import Client
+import ComposableArchitecture
+import ConversationServiceProvider
+import Foundation
+import GitHubCopilotService
+import Logger
+import Persist
+import SharedUIComponents
+import SwiftUI
+import SystemUtils
+import Toast
+
+struct MCPConfigView: View {
+ @State private var mcpConfig: String = ""
+ @Environment(\.toast) var toast
+ @ObservedObject private var featureFlags = FeatureFlagManager.shared
+ @ObservedObject private var copilotPolicy = CopilotPolicyManager.shared
+ @State private var configFilePath: String = mcpConfigFilePath
+ @State private var isMonitoring: Bool = false
+ @State private var lastModificationDate: Date? = nil
+ @State private var fileMonitorTask: Task? = nil
+ @State private var selectedMode: ConversationMode = .defaultAgent
+ @Environment(\.colorScheme) var colorScheme
+
+ private var isCustomAgentEnabled: Bool {
+ copilotPolicy.isCustomAgentEnabled
+ }
+
+ private static var lastSyncTimestamp: Date? = nil
+ @State private var debounceTimer: Timer?
+ private static let refreshDebounceInterval: TimeInterval = 1.0 // 1.0 second debounce
+
+ var body: some View {
+ WithPerceptionTracking {
+ ScrollView {
+ 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)
+ }
+ }
+ .frame(width: 400)
+ .labelsHidden()
+ .pickerStyle(.segmented)
+ .padding(.top, 12)
+ .padding(.bottom, 4)
+
+ Group {
+ if hostAppStore.activeToolsSubTab == .MCP {
+ VStack(alignment: .leading, spacing: 8) {
+ MCPIntroView(isMCPFFEnabled: featureFlags.isMCPEnabled)
+ if featureFlags.isMCPEnabled {
+ MCPManualInstallView()
+
+ 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()
+ if featureFlags.isMCPEnabled {
+ startMonitoringConfigFile()
+ }
+ }
+ .onDisappear {
+ stopMonitoringConfigFile()
+ }
+ .onChange(of: featureFlags.isMCPEnabled) { newMCPFFEnabled in
+ if newMCPFFEnabled {
+ startMonitoringConfigFile()
+ refreshConfiguration()
+ } else {
+ stopMonitoringConfigFile()
+ }
+ }
+ .onChange(of: isCustomAgentEnabled) { isEnabled in
+ if !isEnabled && !selectedMode.isDefaultAgent {
+ selectedMode = .defaultAgent
+ }
+ }
+ } else if hostAppStore.activeToolsSubTab == .BuiltIn {
+ BuiltInToolsListView(
+ selectedMode: $selectedMode,
+ isCustomAgentEnabled: isCustomAgentEnabled
+ )
+ } else {
+ AutoApproveContainerView()
+ }
+ }
+ .padding(.horizontal, 20)
+ }
+ }
+ }
+
+ private func setupConfigFilePath() {
+ let fileManager = FileManager.default
+
+ if !fileManager.fileExists(atPath: configDirectory.path) {
+ try? fileManager.createDirectory(at: configDirectory, withIntermediateDirectories: true)
+ }
+
+ // If the file doesn't exist, create one with a proper structure
+ let configFileURL = URL(fileURLWithPath: configFilePath)
+ if !fileManager.fileExists(atPath: configFilePath) {
+ try? """
+ {
+ "servers": {
+
+ }
+ }
+ """.write(to: configFileURL, atomically: true, encoding: .utf8)
+ }
+
+ // Read the current content from file and ensure it's valid JSON
+ mcpConfig = readAndValidateJSON(from: configFileURL) ?? "{}"
+
+ // Get initial modification date
+ lastModificationDate = getFileModificationDate(url: configFileURL)
+ }
+
+ /// Reads file content and validates it as JSON, returning only the "servers" object
+ private func readAndValidateJSON(from url: URL) -> String? {
+ guard let data = try? Data(contentsOf: url) else {
+ return nil
+ }
+
+ // Try to parse as JSON to validate
+ do {
+ // First verify it's valid JSON
+ let jsonObject = try JSONSerialization.jsonObject(with: data) as? [String: Any]
+
+ // Extract the "servers" object
+ guard let servers = jsonObject?["servers"] as? [String: Any] else {
+ Logger.client.info("No 'servers' key found in MCP configuration")
+ toast("No 'servers' key found in MCP configuration", .error)
+ // Return empty object if no servers section
+ return "{}"
+ }
+
+ // Convert the servers object back to JSON data
+ let serversData = try JSONSerialization.data(
+ withJSONObject: servers, options: [.prettyPrinted])
+
+ // Return as a string
+ return String(data: serversData, encoding: .utf8)
+ } catch {
+ // If parsing fails, return nil
+ Logger.client.info("Parsing MCP JSON error: \(error)")
+ toast("Invalid JSON in MCP configuration file", .error)
+ return nil
+ }
+ }
+
+ private func getFileModificationDate(url: URL) -> Date? {
+ let attributes = try? FileManager.default.attributesOfItem(atPath: url.path)
+ return attributes?[.modificationDate] as? Date
+ }
+
+ private func startMonitoringConfigFile() {
+ 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 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()
+ 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() {
+ if MCPConfigView.lastSyncTimestamp == lastModificationDate {
+ return
+ }
+
+ MCPConfigView.lastSyncTimestamp = lastModificationDate
+
+ let fileURL = URL(fileURLWithPath: configFilePath)
+ if let jsonString = readAndValidateJSON(from: fileURL) {
+ UserDefaults.shared.set(jsonString, for: \.gitHubCopilotMCPConfig)
+ }
+
+ // 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)
+ }
+ }
+ }
+ }
+ }
+}
+
+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/AppState+LanguageModelTools.swift b/Core/Sources/HostApp/ToolsSettings/AppState+LanguageModelTools.swift
new file mode 100644
index 00000000..867a0df1
--- /dev/null
+++ b/Core/Sources/HostApp/ToolsSettings/AppState+LanguageModelTools.swift
@@ -0,0 +1,24 @@
+import ConversationServiceProvider
+import Foundation
+import Persist
+
+public let LANGUAGE_MODEL_TOOLS_STATUS = "languageModelToolsStatus"
+
+extension AppState {
+ public func getLanguageModelToolsStatus() -> [ToolStatusUpdate]? {
+ guard let savedJSON = get(key: LANGUAGE_MODEL_TOOLS_STATUS),
+ let data = try? JSONEncoder().encode(savedJSON),
+ let savedStatus = try? JSONDecoder().decode([ToolStatusUpdate].self, from: data) else {
+ return nil
+ }
+ return savedStatus
+ }
+
+ public func updateLanguageModelToolsStatus(_ updates: [ToolStatusUpdate]) {
+ update(key: LANGUAGE_MODEL_TOOLS_STATUS, value: updates)
+ }
+
+ public func clearLanguageModelToolsStatus() {
+ update(key: LANGUAGE_MODEL_TOOLS_STATUS, value: "")
+ }
+}
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
new file mode 100644
index 00000000..6cdd7a0c
--- /dev/null
+++ b/Core/Sources/HostApp/ToolsSettings/BuiltInToolsListView.swift
@@ -0,0 +1,222 @@
+import Client
+import Combine
+import ConversationServiceProvider
+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) {
+ GroupBox(label: headerView) {
+ contentView
+ }
+ .groupBoxStyle(CardGroupBoxStyle())
+ }
+ .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 {
+ 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)
+ }
+ }
+
+ // MARK: - Content View
+
+ private var contentView: some View {
+ let filteredTools = filteredLanguageModelTools()
+
+ if filteredTools.isEmpty {
+ return AnyView(EmptyStateView())
+ } else {
+ return AnyView(toolsListView(tools: filteredTools))
+ }
+ }
+
+ // MARK: - Tools List View
+
+ private func toolsListView(tools: [LanguageModelTool]) -> some View {
+ VStack(spacing: 0) {
+ ForEach(tools, id: \.name) { tool in
+ ToolRow(
+ toolName: tool.displayName ?? tool.name,
+ toolDescription: tool.displayDescription,
+ toolStatus: tool.status,
+ isServerEnabled: true,
+ isToolEnabled: toolBindingFor(tool),
+ isInteractionAllowed: isInteractionAllowed(),
+ onToolToggleChanged: { isEnabled in
+ handleToolToggleChange(tool: tool, isEnabled: isEnabled)
+ }
+ )
+ }
+ }
+ }
+
+ // MARK: - Helper Methods
+
+ private func initializeToolStates() {
+ // When mode changes, recalculate everything from scratch
+ var map: [String: Bool] = [:]
+ for tool in builtInToolManager.availableLanguageModelTools {
+ map[tool.name] = isToolEnabledInMode(tool)
+ }
+ toolEnabledStates = map
+ }
+
+ private func toolBindingFor(_ tool: LanguageModelTool) -> Binding {
+ Binding(
+ get: {
+ toolEnabledStates[tool.name] ?? isToolEnabledInMode(tool)
+ },
+ set: { newValue in
+ toolEnabledStates[tool.name] = newValue
+ }
+ )
+ }
+
+ private func filteredLanguageModelTools() -> [LanguageModelTool] {
+ let key = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ guard !key.isEmpty else { return builtInToolManager.availableLanguageModelTools }
+
+ return builtInToolManager.availableLanguageModelTools.filter { tool in
+ tool.name.lowercased().contains(key) ||
+ (tool.description?.lowercased().contains(key) ?? false) ||
+ (tool.displayName?.lowercased().contains(key) ?? false)
+ }
+ }
+
+ private func handleToolToggleChange(tool: LanguageModelTool, isEnabled: Bool) {
+ let toolUpdate = ToolStatusUpdate(name: tool.name, status: isEnabled ? .enabled : .disabled)
+ updateToolStatus([toolUpdate])
+ }
+
+ private func updateToolStatus(_ toolUpdates: [ToolStatusUpdate]) {
+ Task {
+ do {
+ let service = try getService()
+
+ 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")
+ }
+ }
+ } 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
+private struct EmptyStateView: View {
+ var body: some View {
+ Text("No built-in tools available. Make sure background permissions are granted.")
+ .foregroundColor(.secondary)
+ }
+}
diff --git a/Core/Sources/HostApp/ToolsSettings/CopilotBuiltInToolManagerObservable.swift b/Core/Sources/HostApp/ToolsSettings/CopilotBuiltInToolManagerObservable.swift
new file mode 100644
index 00000000..ae36f221
--- /dev/null
+++ b/Core/Sources/HostApp/ToolsSettings/CopilotBuiltInToolManagerObservable.swift
@@ -0,0 +1,51 @@
+import Client
+import Combine
+import ConversationServiceProvider
+import Logger
+import Persist
+import SwiftUI
+
+class CopilotBuiltInToolManagerObservable: ObservableObject {
+ static let shared = CopilotBuiltInToolManagerObservable()
+
+ @Published var availableLanguageModelTools: [LanguageModelTool] = []
+ private var cancellables = Set()
+
+ private init() {
+ DistributedNotificationCenter.default()
+ .publisher(for: .gitHubCopilotToolsDidChange)
+ .receive(on: DispatchQueue.main)
+ .sink { [weak self] _ in
+ guard let self else { return }
+ Task {
+ await self.refreshLanguageModelTools()
+ }
+ }
+ .store(in: &cancellables)
+
+ Task {
+ await refreshLanguageModelTools()
+ }
+ }
+
+ @MainActor
+ public func refreshLanguageModelTools() async {
+ do {
+ let service = try getService()
+ let languageModelTools = try await service.getAvailableLanguageModelTools()
+
+ guard let tools = languageModelTools else { return }
+
+ // Update the published list with all tools (both enabled and disabled)
+ availableLanguageModelTools = tools
+
+ // Update AppState for persistence
+ let statusUpdates = tools.map {
+ ToolStatusUpdate(name: $0.name, status: $0.status)
+ }
+ AppState.shared.updateLanguageModelToolsStatus(statusUpdates)
+ } catch {
+ Logger.client.error("Failed to fetch language model tools: \(error)")
+ }
+ }
+}
diff --git a/Core/Sources/HostApp/MCPSettings/CopilotMCPToolManagerObservable.swift b/Core/Sources/HostApp/ToolsSettings/CopilotMCPToolManagerObservable.swift
similarity index 75%
rename from Core/Sources/HostApp/MCPSettings/CopilotMCPToolManagerObservable.swift
rename to Core/Sources/HostApp/ToolsSettings/CopilotMCPToolManagerObservable.swift
index d493b8be..8c3444d2 100644
--- a/Core/Sources/HostApp/MCPSettings/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/MCPSettings/MCPConfigConstants.swift b/Core/Sources/HostApp/ToolsSettings/MCPConfigConstants.swift
similarity index 100%
rename from Core/Sources/HostApp/MCPSettings/MCPConfigConstants.swift
rename to Core/Sources/HostApp/ToolsSettings/MCPConfigConstants.swift
diff --git a/Core/Sources/HostApp/ToolsSettings/MCPIntroView.swift b/Core/Sources/HostApp/ToolsSettings/MCPIntroView.swift
new file mode 100644
index 00000000..ac84bcce
--- /dev/null
+++ b/Core/Sources/HostApp/ToolsSettings/MCPIntroView.swift
@@ -0,0 +1,45 @@
+import Client
+import Foundation
+import Logger
+import SharedUIComponents
+import SwiftUI
+
+struct MCPIntroView: View {
+ let isMCPFFEnabled: Bool
+
+ public init(isMCPFFEnabled: Bool) {
+ self.isMCPFFEnabled = isMCPFFEnabled
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ if !isMCPFFEnabled {
+ GroupBox {
+ HStack(alignment: .top, spacing: 8) {
+ Image(systemName: "info.circle.fill")
+ .font(.body)
+ .foregroundColor(.gray)
+ Text(
+ "MCP servers are disabled by your organizationโs policy. To enable them, 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)
+ )
+ )
+ }
+ }
+ }
+}
+
+#Preview {
+ MCPIntroView(isMCPFFEnabled: true)
+ .frame(width: 800)
+}
+
+#Preview {
+ MCPIntroView(isMCPFFEnabled: false)
+ .frame(width: 800)
+}
diff --git a/Core/Sources/HostApp/ToolsSettings/MCPManualInstallView.swift b/Core/Sources/HostApp/ToolsSettings/MCPManualInstallView.swift
new file mode 100644
index 00000000..6909b851
--- /dev/null
+++ b/Core/Sources/HostApp/ToolsSettings/MCPManualInstallView.swift
@@ -0,0 +1,145 @@
+import AppKit
+import Logger
+import SharedUIComponents
+import SwiftUI
+
+struct MCPManualInstallView: View {
+ @State private var isExpanded: Bool = false
+
+ var body: some View {
+ VStack(spacing: 0) {
+ DisclosureSettingsRow(
+ isExpanded: $isExpanded,
+ 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) {
+ Button {
+ openMCPRunTimeLogFolder()
+ } label: {
+ HStack(spacing: 0) {
+ Image(systemName: "folder")
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 12, height: 12, alignment: .center)
+ .padding(4)
+ Text("Open MCP Log Folder")
+ }
+ .conditionalFontWeight(.semibold)
+ }
+ .buttonStyle(.bordered)
+ .help("Open MCP Runtime Log Folder")
+
+ Button {
+ openConfigFile()
+ } label: {
+ HStack(spacing: 0) {
+ Image(systemName: "square.and.pencil")
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 12, height: 12, alignment: .center)
+ .padding(4)
+ Text("Edit Config")
+ }
+ .conditionalFontWeight(.semibold)
+ }
+ .buttonStyle(.bordered)
+ .help("Configure your MCP server")
+ }
+ .padding(.vertical, 12)
+ }
+ )
+
+ if isExpanded {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack(spacing: 8) {
+ Text("Example Configuration").foregroundColor(.primary.opacity(0.85))
+ CopyButton(
+ copy: {
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(exampleConfig, forType: .string)
+ },
+ foregroundColor: .primary.opacity(0.85),
+ fontWeight: .semibold
+ )
+ .frame(width: 10, height: 10)
+ }
+ .padding(.leading, 4)
+
+ exampleConfigView()
+ }
+ .padding(.top, 8)
+ .padding([.leading, .trailing, .bottom], 20)
+ .background(QuaternarySystemFillColor.opacity(0.75))
+ .transition(.opacity.combined(with: .scale(scale: 1, anchor: .top)))
+ }
+ }
+ .settingsContainerStyle(isExpanded: isExpanded)
+ }
+
+ var exampleConfig: String {
+ """
+ {
+ "servers": {
+ "my-mcp-server": {
+ "type": "stdio",
+ "command": "my-command",
+ "args": [],
+ "env": {
+ "TOKEN": "my_token"
+ }
+ }
+ }
+ }
+ """
+ }
+
+ @ViewBuilder
+ private func exampleConfigView() -> some View {
+ Text(exampleConfig)
+ .font(.system(.body, design: .monospaced))
+ .padding(.horizontal, 16)
+ .padding(.top, 8)
+ .padding(.bottom, 6)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(
+ Color(nsColor: .textBackgroundColor).opacity(0.5)
+ )
+ .textSelection(.enabled)
+ .cornerRadius(6)
+ .overlay(
+ RoundedRectangle(cornerRadius: 6)
+ .inset(by: 0.5)
+ .stroke(Color("GroupBoxStrokeColor"), lineWidth: 1)
+ )
+ }
+
+ private func openMCPRunTimeLogFolder() {
+ let url = URL(
+ fileURLWithPath: FileLoggingLocation.mcpRuntimeLogsPath.description,
+ isDirectory: true
+ )
+
+ // Create directory if it doesn't exist
+ if !FileManager.default.fileExists(atPath: url.path) {
+ do {
+ try FileManager.default.createDirectory(
+ atPath: url.path,
+ withIntermediateDirectories: true,
+ attributes: nil
+ )
+ } catch {
+ Logger.client.error("Failed to create MCP runtime log folder: \(error)")
+ return
+ }
+ }
+
+ NSWorkspace.shared.open(url)
+ }
+
+ private func openConfigFile() {
+ let url = URL(fileURLWithPath: mcpConfigFilePath)
+ NSWorkspace.shared.open(url)
+ }
+}
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
new file mode 100644
index 00000000..47abc27a
--- /dev/null
+++ b/Core/Sources/HostApp/ToolsSettings/MCPServerToolsSection.swift
@@ -0,0 +1,459 @@
+import SwiftUI
+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)
+ .foregroundStyle(
+ serverTools.status == .running ? .primary : .tertiary
+ )
+ if serverTools.status == .error || serverTools.status == .blocked {
+ let message = extractErrorMessage(serverTools.error?.description ?? "")
+ 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))
+ }
+ }
+ }
+
+ 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 {
+ 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)
+ }
+ .padding(.leading, 4)
+ }
+
+ private var divider: some View {
+ Divider()
+ .padding(.leading, 36)
+ .padding(.top, 2)
+ .padding(.bottom, 4)
+ }
+
+ private var toolsList: some View {
+ VStack(spacing: 0) {
+ divider
+ ForEach(serverTools.tools, id: \.name) { tool in
+ ToolRow(
+ toolName: tool.name,
+ toolDescription: tool.description,
+ toolStatus: tool._status,
+ isServerEnabled: isServerEnabled,
+ isToolEnabled: toolBindingFor(tool),
+ isInteractionAllowed: isInteractionAllowed,
+ onToolToggleChanged: { handleToolToggleChange(tool: tool, isEnabled: $0) }
+ )
+ .padding(.leading, 36)
+ }
+ }
+ .onChange(of: serverTools) { newValue in
+ initializeToolStates(server: newValue)
+ updateMixedState()
+ }
+ }
+
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ // Conditional view rendering based on error state
+ if serverTools.status == .error || serverTools.status == .blocked {
+ // No disclosure group for error state
+ VStack(spacing: 0) {
+ serverToggle
+ .padding(.leading, 11)
+ .padding(.trailing, 4)
+ divider.padding(.top, 4)
+ }
+ } else {
+ // Regular DisclosureGroup for non-error state
+ DisclosureGroup(isExpanded: $isExpanded) {
+ toolsList
+ } label: {
+ serverToggle
+ }
+ .onAppear {
+ initializeToolStates(server: serverTools)
+ updateMixedState()
+ if forceExpand {
+ isExpanded = true
+ }
+ }
+ .onChange(of: forceExpand) { newForceExpand in
+ if newForceExpand {
+ 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 {
+ guard let messageRange = description.range(of: "message:"),
+ let stackRange = description.range(of: "stack:") else {
+ return description
+ }
+ let start = description.index(messageRange.upperBound, offsetBy: 0)
+ 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] ?? isToolEnabledInMode(tool.name, currentStatus: tool._status)
+ },
+ set: { toolEnabledStates[tool.name] = $0 }
+ )
+ }
+
+ private func handleToolToggleChange(tool: MCPTool, isEnabled: Bool) {
+ toolEnabledStates[tool.name] = isEnabled
+
+ // Update server state based on tool states
+ updateServerState()
+
+ // Update mixed state
+ updateMixedState()
+
+ // Update only this specific tool status
+ updateToolStatus(tool: tool, isEnabled: isEnabled)
+ }
+
+ private func updateServerState() {
+ // If any tool is enabled, server should be enabled
+ // If all tools are disabled, server should be disabled
+ let allToolsDisabled = serverTools.tools.allSatisfy { tool in
+ !(toolEnabledStates[tool.name] ?? (tool._status == .enabled))
+ }
+
+ isServerEnabled = !allToolsDisabled
+ }
+
+ private func updateToolStatus(tool: MCPTool, isEnabled: Bool) {
+ let serverUpdate = UpdateMCPToolsStatusServerCollection(
+ name: serverTools.name,
+ tools: [UpdatedMCPToolsStatus(name: tool.name, status: isEnabled ? .enabled : .disabled)]
+ )
+
+ updateMCPStatus([serverUpdate])
+ }
+
+ private func updateAllToolsStatus(enabled: Bool) {
+ isServerEnabled = enabled
+
+ // Get all tools for this server from the original collection
+ let allServerTools = CopilotMCPToolManagerObservable.shared.availableMCPServerTools
+ .first(where: { $0.name == originalServerName })?.tools ?? serverTools.tools
+
+ // Update all tool states - includes both visible and filtered-out tools
+ for tool in allServerTools {
+ toolEnabledStates[tool.name] = enabled
+ }
+
+ // Create status update for all tools
+ let serverUpdate = UpdateMCPToolsStatusServerCollection(
+ name: serverTools.name,
+ 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]) {
+ let isDefaultAgentMode = selectedMode.isDefaultAgent
+ Task {
+ do {
+ let service = try getService()
+
+ 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/MCPSettings/MCPToolsListContainerView.swift b/Core/Sources/HostApp/ToolsSettings/MCPToolsListContainerView.swift
similarity index 72%
rename from Core/Sources/HostApp/MCPSettings/MCPToolsListContainerView.swift
rename to Core/Sources/HostApp/ToolsSettings/MCPToolsListContainerView.swift
index 27f2d6cb..ecf30952 100644
--- a/Core/Sources/HostApp/MCPSettings/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/MCPSettings/MCPToolsListView.swift b/Core/Sources/HostApp/ToolsSettings/MCPToolsListView.swift
similarity index 72%
rename from Core/Sources/HostApp/MCPSettings/MCPToolsListView.swift
rename to Core/Sources/HostApp/ToolsSettings/MCPToolsListView.swift
index 3b0cbe42..ba8e1b4f 100644
--- a/Core/Sources/HostApp/MCPSettings/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
@@ -86,9 +108,7 @@ private struct EmptyStateView: View {
// Private components now defined in separate files:
// MCPToolsListContainerView - in MCPToolsListContainerView.swift
// MCPServerToolsSection - in MCPServerToolsSection.swift
-// MCPToolRow - in MCPToolRowView.swift
/// Private alias for maintaining backward compatibility
private typealias ToolsListView = MCPToolsListContainerView
private typealias ServerToolsSection = MCPServerToolsSection
-private typealias ToolRow = MCPToolRow
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/MCPSettings/MCPToolRowView.swift b/Core/Sources/HostApp/ToolsSettings/ToolRowView.swift
similarity index 63%
rename from Core/Sources/HostApp/MCPSettings/MCPToolRowView.swift
rename to Core/Sources/HostApp/ToolsSettings/ToolRowView.swift
index f6a8e20f..d8df5965 100644
--- a/Core/Sources/HostApp/MCPSettings/MCPToolRowView.swift
+++ b/Core/Sources/HostApp/ToolsSettings/ToolRowView.swift
@@ -1,24 +1,30 @@
import SwiftUI
-import GitHubCopilotService
+import ConversationServiceProvider
/// Individual tool row
-struct MCPToolRow: View {
- let tool: MCPTool
+struct ToolRow: View {
+ let toolName: String
+ let toolDescription: String?
+ 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) {
- Text(tool.name).fontWeight(.medium)
+ Text(toolName).fontWeight(.medium)
- if let description = tool.description {
+ if let description = toolDescription {
Text(description)
.font(.system(size: 11))
.foregroundColor(.secondary)
@@ -30,10 +36,8 @@ struct MCPToolRow: View {
Divider().padding(.vertical, 4)
}
}
+ .disabled(!isInteractionAllowed)
}
- .padding(.leading, 36)
.padding(.vertical, 0)
- .onChange(of: tool._status) { 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 77d91bb0..8d73d1d3 100644
--- a/Core/Sources/PersistMiddleware/Extensions/ChatMessage+Storage.swift
+++ b/Core/Sources/PersistMiddleware/Extensions/ChatMessage+Storage.swift
@@ -8,64 +8,121 @@ extension ChatMessage {
struct TurnItemData: Codable {
var content: String
+ var contentImageReferences: [ImageReference]
var rating: ConversationRating
var references: [ConversationReference]
var followUp: ConversationFollowUp?
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 {
let container = try decoder.container(keyedBy: CodingKeys.self)
content = try container.decode(String.self, forKey: .content)
+ contentImageReferences = try container.decodeIfPresent([ImageReference].self, forKey: .contentImageReferences) ?? []
rating = try container.decode(ConversationRating.self, forKey: .rating)
references = try container.decode([ConversationReference].self, forKey: .references)
followUp = try container.decodeIfPresent(ConversationFollowUp.self, forKey: .followUp)
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
init(
content: String,
+ contentImageReferences: [ImageReference]? = nil,
rating: ConversationRating,
references: [ConversationReference],
followUp: ConversationFollowUp?,
suggestedTitle: String?,
errorMessages: [String] = [],
steps: [ConversationProgressStep]?,
+ thinking: [MessageThinking] = [],
editAgentRounds: [AgentRound]? = nil,
- panelMessages: [CopilotShowMessageParams]? = nil
+ parentTurnId: String? = nil,
+ panelMessages: [CopilotShowMessageParams]? = nil,
+ fileEdits: [FileEdit]? = nil,
+ turnStatus: ChatMessage.TurnStatus? = nil,
+ requestType: RequestType = .conversation,
+ modelName: String? = nil,
+ modelProviderName: String? = nil,
+ billingMultiplier: Float? = nil,
+ reasoningEffort: String? = nil
) {
self.content = content
+ self.contentImageReferences = contentImageReferences ?? []
self.rating = rating
self.references = references
self.followUp = followUp
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
}
}
func toTurnItem() -> TurnItem {
let turnItemData = TurnItemData(
content: self.content,
+ contentImageReferences: self.contentImageReferences,
rating: self.rating,
references: self.references,
followUp: self.followUp,
suggestedTitle: self.suggestedTitle,
errorMessages: self.errorMessages,
steps: self.steps,
+ thinking: self.thinking,
editAgentRounds: self.editAgentRounds,
- panelMessages: self.panelMessages
+ parentTurnId: self.parentTurnId,
+ panelMessages: self.panelMessages,
+ fileEdits: self.fileEdits,
+ turnStatus: self.turnStatus,
+ requestType: self.requestType,
+ modelName: self.modelName,
+ modelProviderName: self.modelProviderName,
+ billingMultiplier: self.billingMultiplier,
+ reasoningEffort: self.reasoningEffort
)
// TODO: handle exception
@@ -90,6 +147,7 @@ extension ChatMessage {
clsTurnID: turnItem.CLSTurnID,
role: ChatMessage.Role(rawValue: turnItem.role)!,
content: turnItemData.content,
+ contentImageReferences: turnItemData.contentImageReferences,
references: turnItemData.references,
followUp: turnItemData.followUp,
suggestedTitle: turnItemData.suggestedTitle,
@@ -97,7 +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 ad7849fa..b64e841c 100644
--- a/Core/Sources/Service/XPCService.swift
+++ b/Core/Sources/Service/XPCService.swift
@@ -9,6 +9,8 @@ import XPCShared
import HostAppActivator
import XcodeInspector
import GitHubCopilotViewModel
+import Workspace
+import ConversationServiceProvider
public class XPCService: NSObject, XPCServiceProtocol {
// MARK: - Service
@@ -119,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,
@@ -128,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,
@@ -228,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()
@@ -289,22 +332,221 @@ 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
+ // 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)
+ }
+ }
+ }
+
+ // MARK: - MCP Registry
+
+ public func listMCPRegistryServers(_ params: Data, withReply reply: @escaping (Data?, Error?) -> Void) {
+ let decoder = JSONDecoder()
+ var listMCPRegistryServersParams: MCPRegistryListServersParams?
+ do {
+ listMCPRegistryServersParams = try decoder.decode(MCPRegistryListServersParams.self, from: params)
+ } catch {
+ Logger.service.error("Failed to decode MCP Registry list servers parameters: \(error)")
+ return
+ }
+
+ Task { @MainActor in
+ do {
+ let service = try GitHubCopilotViewModel.shared.getGitHubCopilotAuthService()
+ let response = try await service.listMCPRegistryServers(listMCPRegistryServersParams!)
+ let data = try? JSONEncoder().encode(response)
+ reply(data, nil)
+ } catch {
+ Logger.service.error("Failed to list MCP Registry servers: \(error)")
+ reply(nil, NSError.from(error))
+ }
+ }
+ }
+
+ public func getMCPRegistryServer(_ params: Data, withReply reply: @escaping (Data?, Error?) -> Void) {
+ let decoder = JSONDecoder()
+ var getMCPRegistryServerParams: MCPRegistryGetServerParams?
+ do {
+ getMCPRegistryServerParams = try decoder.decode(MCPRegistryGetServerParams.self, from: params)
+ } catch {
+ Logger.service.error("Failed to decode MCP Registry get server parameters: \(error)")
+ return
+ }
+
+ Task { @MainActor in
+ do {
+ let service = try GitHubCopilotViewModel.shared.getGitHubCopilotAuthService()
+ let response = try await service.getMCPRegistryServer(getMCPRegistryServerParams!)
+ let data = try? JSONEncoder().encode(response)
+ reply(data, nil)
+ } catch {
+ Logger.service.error("Failed to get MCP Registry servers: \(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))
+ }
+ }
+ }
+
+ // MARK: - Language Model Tools
+ public func getAvailableLanguageModelTools(withReply reply: @escaping (Data?) -> Void) {
+ let availableLanguageModelTools = CopilotLanguageModelToolManager.getAvailableLanguageModelTools()
+ if let availableLanguageModelTools = availableLanguageModelTools {
+ // Encode and send the data
+ let data = try? JSONEncoder().encode(availableLanguageModelTools)
+ reply(data)
+ } else {
+ reply(nil)
+ }
+ }
+
+ 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 or workspace folders: \(error)")
+ reply(nil)
return
}
Task { @MainActor in
- await GitHubCopilotService.updateAllClsMCP(collections: collections)
+ 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)
+ reply(data)
+ } catch {
+ Logger.service.error("Failed to encode updated tools: \(error)")
+ reply(nil)
+ }
}
}
@@ -317,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
@@ -338,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/ChatPanelWindow.swift b/Core/Sources/SuggestionWidget/ChatPanelWindow.swift
index d6cf456d..543afb3e 100644
--- a/Core/Sources/SuggestionWidget/ChatPanelWindow.swift
+++ b/Core/Sources/SuggestionWidget/ChatPanelWindow.swift
@@ -4,12 +4,14 @@ import ComposableArchitecture
import Foundation
import SwiftUI
import ConversationTab
+import SharedUIComponents
final class ChatPanelWindow: NSWindow {
override var canBecomeKey: Bool { true }
override var canBecomeMain: Bool { true }
private let storeObserver = NSObject()
+ private let fontScaleManager: FontScaleManager = .shared
var minimizeWindow: () -> Void = {}
@@ -121,4 +123,21 @@ final class ChatPanelWindow: NSWindow {
override func close() {
minimizeWindow()
}
+
+ override func performKeyEquivalent(with event: NSEvent) -> Bool {
+ if event.modifierFlags.contains(.command) {
+ switch event.charactersIgnoringModifiers {
+ case "-":
+ fontScaleManager.decreaseFontScale()
+ return true
+ case "=":
+ fontScaleManager.increaseFontScale()
+ return true
+ default:
+ break
+ }
+ }
+
+ return super.performKeyEquivalent(with: event)
+ }
}
diff --git a/Core/Sources/SuggestionWidget/ChatWindow/ChatHistoryView.swift b/Core/Sources/SuggestionWidget/ChatWindow/ChatHistoryView.swift
index a766e949..bb3747c6 100644
--- a/Core/Sources/SuggestionWidget/ChatWindow/ChatHistoryView.swift
+++ b/Core/Sources/SuggestionWidget/ChatWindow/ChatHistoryView.swift
@@ -19,18 +19,20 @@ struct ChatHistoryView: View {
VStack(alignment: .center, spacing: 0) {
Header(isChatHistoryVisible: $isChatHistoryVisible)
- .frame(height: 32)
- .padding(.leading, 16)
- .padding(.trailing, 12)
+ .scaledFrame(height: 32)
+ .scaledPadding(.leading, 12)
+ .scaledPadding(.trailing, 8)
Divider()
ChatHistorySearchBarView(searchText: $searchText)
- .padding(.horizontal, 16)
- .padding(.vertical, 4)
+ .scaledPadding(.leading, 12)
+ .scaledPadding(.trailing, 8)
+ .scaledPadding(.vertical, 8)
ItemView(store: store, searchText: $searchText, isChatHistoryVisible: $isChatHistoryVisible)
- .padding(.horizontal, 16)
+ .scaledPadding(.leading, 12)
+ .scaledPadding(.trailing, 8)
}
}
}
@@ -42,8 +44,9 @@ struct ChatHistoryView: View {
var body: some View {
HStack {
Text("Chat History")
- .font(.system(size: 13, weight: .bold))
- .lineLimit(nil)
+ .scaledFont(size: 13, weight: .bold)
+ .scaledPadding(.leading, 4)
+ .scaledFrame(maxWidth: 192, alignment: .leading)
Spacer()
@@ -51,6 +54,7 @@ struct ChatHistoryView: View {
isChatHistoryVisible = false
}) {
Image(systemName: "xmark")
+ .scaledFont(.body)
}
.buttonStyle(HoverButtonStyle())
.help("Close")
@@ -79,7 +83,7 @@ struct ChatHistoryView: View {
refreshStoredChatTabInfos()
}
.id(previewInfo.id)
- .frame(height: 61)
+ .scaledFrame(height: 61)
}
}
}
@@ -123,8 +127,10 @@ struct ChatHistorySearchBarView: View {
HStack(spacing: 5) {
Image(systemName: "magnifyingglass")
.foregroundColor(.secondary)
+ .scaledFont(.body)
TextField("Search", text: $searchText)
+ .scaledFont(.body)
.textFieldStyle(PlainTextFieldStyle())
.focused($isSearchBarFocused)
.foregroundColor(searchText.isEmpty ? Color(nsColor: .placeholderTextColor) : Color(nsColor: .textColor))
@@ -171,12 +177,13 @@ struct ChatHistoryItemView: View {
// directly get title from chat tab info
Text(previewInfo.title ?? "New Chat")
.frame(alignment: .leading)
- .font(.system(size: 14, weight: .semibold))
+ .scaledFont(size: 14, weight: .semibold)
.foregroundColor(.primary)
.lineLimit(1)
if isTabSelected() {
Text("Current")
+ .scaledFont(.footnote)
.foregroundStyle(.secondary)
}
@@ -186,7 +193,7 @@ struct ChatHistoryItemView: View {
HStack(spacing: 0) {
Text(formatDate(previewInfo.updatedAt))
.frame(alignment: .leading)
- .font(.system(size: 13, weight: .regular))
+ .scaledFont(size: 13, weight: .regular)
.foregroundColor(.secondary)
.lineLimit(1)
@@ -205,6 +212,7 @@ struct ChatHistoryItemView: View {
}) {
Image(systemName: "trash")
.foregroundColor(.primary)
+ .scaledFont(.body)
.opacity(isHovered ? 1 : 0)
}
.buttonStyle(HoverButtonStyle())
@@ -215,6 +223,7 @@ struct ChatHistoryItemView: View {
.padding(.horizontal, 12)
}
.frame(maxHeight: .infinity)
+ .contentShape(Rectangle())
.onHover(perform: {
isHovered = $0
})
@@ -224,7 +233,7 @@ struct ChatHistoryItemView: View {
nsColor: .controlColor
.withAlphaComponent(colorScheme == .dark ? 0.1 : 0.55)
),
- cornerRadius: 4,
+ cornerRadius: 8,
showBorder: isHovered,
borderColor: Color(nsColor: .separatorColor)
)
diff --git a/Core/Sources/SuggestionWidget/ChatWindow/ChatLoginView.swift b/Core/Sources/SuggestionWidget/ChatWindow/ChatLoginView.swift
index 871dd24e..0a70e8ba 100644
--- a/Core/Sources/SuggestionWidget/ChatWindow/ChatLoginView.swift
+++ b/Core/Sources/SuggestionWidget/ChatWindow/ChatLoginView.swift
@@ -17,15 +17,15 @@ struct ChatLoginView: View {
.resizable()
.renderingMode(.template)
.scaledToFill()
- .frame(width: 60.0, height: 60.0)
+ .scaledFrame(width: 60.0, height: 60.0)
.foregroundColor(.secondary)
Text("Welcome to Copilot")
- .font(.largeTitle)
+ .scaledFont(.largeTitle)
.multilineTextAlignment(.center)
Text("Your AI-powered coding assistant")
- .font(.body)
+ .scaledFont(.body)
.multilineTextAlignment(.center)
}
@@ -37,11 +37,15 @@ struct ChatLoginView: View {
openURL(url)
}
}
+ .scaledFont(.body)
.buttonStyle(.borderedProminent)
HStack{
Text("Already have an account?")
+ .scaledFont(.body)
+
Button("Sign In") { viewModel.signIn() }
+ .scaledFont(.body)
.buttonStyle(.borderless)
.foregroundColor(Color("TextLinkForegroundColor"))
@@ -51,11 +55,11 @@ struct ChatLoginView: View {
}
}
}
- .padding(.top, 16)
+ .scaledPadding(.top, 16)
Spacer()
Text("Copilot Free and Copilot Pro may show [public code](https://aka.ms/github-copilot-match-public-code) suggestions and collect telemetry. You can change these [GitHub settings](https://aka.ms/github-copilot-settings) at any time. By continuing, you agree to our [terms](https://github.com/customer-terms/github-copilot-product-specific-terms) and [privacy policy](https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement).")
- .font(.system(size: 12))
+ .scaledFont(.system(size: 12))
}
.padding()
.frame(
@@ -63,7 +67,7 @@ struct ChatLoginView: View {
maxHeight: .infinity
)
}
- .xcodeStyleFrame(cornerRadius: 10)
+ .xcodeStyleFrame()
.ignoresSafeArea(edges: .top)
.alert(
viewModel.signInResponse?.userCode ?? "",
@@ -71,7 +75,10 @@ struct ChatLoginView: View {
presenting: viewModel.signInResponse
) { _ in
Button("Cancel", role: .cancel, action: {})
- Button("Copy Code and Open", action: viewModel.copyAndOpen)
+ .scaledFont(.body)
+
+ Button("Copy Code and Open", action: { viewModel.copyAndOpen(fromHostApp: false) })
+ .scaledFont(.body)
} message: { response in
Text("""
Please enter the above code in the GitHub website \
diff --git a/Core/Sources/SuggestionWidget/ChatWindow/ChatNoAXPermissionView.swift b/Core/Sources/SuggestionWidget/ChatWindow/ChatNoAXPermissionView.swift
index 299c46cc..f6674e4a 100644
--- a/Core/Sources/SuggestionWidget/ChatWindow/ChatNoAXPermissionView.swift
+++ b/Core/Sources/SuggestionWidget/ChatWindow/ChatNoAXPermissionView.swift
@@ -14,15 +14,15 @@ struct ChatNoAXPermissionView: View {
.resizable()
.renderingMode(.template)
.scaledToFill()
- .frame(width: 64.0, height: 64.0)
+ .scaledFrame(width: 64.0, height: 64.0)
.foregroundColor(.primary)
Text("Accessibility Permission Required")
- .font(.largeTitle)
+ .scaledFont(.largeTitle)
.multilineTextAlignment(.center)
Text("Please grant accessibility permission for Github Copilot to work with Xcode.")
- .font(.body)
+ .scaledFont(.body)
.multilineTextAlignment(.center)
HStack{
@@ -31,6 +31,7 @@ struct ChatNoAXPermissionView: View {
openURL(url)
}
}
+ .scaledFont(.body)
.buttonStyle(.borderedProminent)
}
@@ -42,7 +43,7 @@ struct ChatNoAXPermissionView: View {
maxHeight: .infinity
)
}
- .xcodeStyleFrame(cornerRadius: 10)
+ .xcodeStyleFrame()
.ignoresSafeArea(edges: .top)
}
}
diff --git a/Core/Sources/SuggestionWidget/ChatWindow/ChatNoSubscriptionView.swift b/Core/Sources/SuggestionWidget/ChatWindow/ChatNoSubscriptionView.swift
index 5c052411..1ecbfc90 100644
--- a/Core/Sources/SuggestionWidget/ChatWindow/ChatNoSubscriptionView.swift
+++ b/Core/Sources/SuggestionWidget/ChatWindow/ChatNoSubscriptionView.swift
@@ -16,15 +16,15 @@ struct ChatNoSubscriptionView: View {
.resizable()
.renderingMode(.template)
.scaledToFill()
- .frame(width: 60.0, height: 60.0)
+ .scaledFrame(width: 60.0, height: 60.0)
.foregroundColor(.primary)
Text("No Copilot Subscription Found")
- .font(.system(size: 24))
+ .scaledFont(.system(size: 24))
.multilineTextAlignment(.center)
Text("Request a license from your organization manager \nor start a 30-day [free trial](https://github.com/github-copilot/signup/copilot_individual) to explore Copilot")
- .font(.system(size: 12))
+ .scaledFont(.system(size: 12))
.multilineTextAlignment(.center)
HStack{
@@ -33,9 +33,11 @@ struct ChatNoSubscriptionView: View {
openURL(url)
}
}
+ .scaledFont(.body)
.buttonStyle(.borderedProminent)
Button("Retry") { viewModel.checkStatus() }
+ .scaledFont(.body)
.buttonStyle(.bordered)
if viewModel.isRunningAction || viewModel.waitingForSignIn {
@@ -47,7 +49,7 @@ struct ChatNoSubscriptionView: View {
Spacer()
Text("Copilot Free and Copilot Pro may show [public code](https://aka.ms/github-copilot-match-public-code) suggestions and collect telemetry. You can change these [GitHub settings](https://aka.ms/github-copilot-settings) at any time. By continuing, you agree to our [terms](https://github.com/customer-terms/github-copilot-product-specific-terms) and [privacy policy](https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement).")
- .font(.system(size: 12))
+ .scaledFont(.system(size: 12))
}
.padding()
.frame(
@@ -55,7 +57,7 @@ struct ChatNoSubscriptionView: View {
maxHeight: .infinity
)
}
- .xcodeStyleFrame(cornerRadius: 10)
+ .xcodeStyleFrame()
.ignoresSafeArea(edges: .top)
}
}
diff --git a/Core/Sources/SuggestionWidget/ChatWindow/ChatNoWorkspaceView.swift b/Core/Sources/SuggestionWidget/ChatWindow/ChatNoWorkspaceView.swift
index 8d7cbf60..9e342bca 100644
--- a/Core/Sources/SuggestionWidget/ChatWindow/ChatNoWorkspaceView.swift
+++ b/Core/Sources/SuggestionWidget/ChatWindow/ChatNoWorkspaceView.swift
@@ -13,15 +13,15 @@ struct ChatNoWorkspaceView: View {
.resizable()
.renderingMode(.template)
.scaledToFill()
- .frame(width: 64.0, height: 64.0)
+ .scaledFrame(width: 64.0, height: 64.0)
.foregroundColor(.secondary)
Text("No Active Xcode Workspace")
- .font(.largeTitle)
+ .scaledFont(.largeTitle)
.multilineTextAlignment(.center)
Text("To use Copilot, open Xcode with an active workspace in focus")
- .font(.body)
+ .scaledFont(.body)
.multilineTextAlignment(.center)
}
@@ -35,7 +35,7 @@ struct ChatNoWorkspaceView: View {
maxHeight: .infinity
)
}
- .xcodeStyleFrame(cornerRadius: 10)
+ .xcodeStyleFrame()
.ignoresSafeArea(edges: .top)
}
}
diff --git a/Core/Sources/SuggestionWidget/ChatWindow/CopilotIntroView.swift b/Core/Sources/SuggestionWidget/ChatWindow/CopilotIntroView.swift
index b3d5eb5b..a7fdcec7 100644
--- a/Core/Sources/SuggestionWidget/ChatWindow/CopilotIntroView.swift
+++ b/Core/Sources/SuggestionWidget/ChatWindow/CopilotIntroView.swift
@@ -76,15 +76,15 @@ struct CopilotIntroItemView: View {
.padding(.leading, 8)
Text(title)
- .font(.body)
.kerning(0.096)
+ .scaledFont(.body)
.multilineTextAlignment(.center)
.foregroundColor(.primary)
}
.frame(maxWidth: .infinity, alignment: .leading)
Text(description)
- .font(.body)
+ .scaledFont(.body)
.foregroundColor(.secondary)
.padding(.leading, 28)
.padding(.top, 4)
diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift
index c0004ec7..f5530b28 100644
--- a/Core/Sources/SuggestionWidget/ChatWindowView.swift
+++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift
@@ -59,24 +59,23 @@ struct ChatView: View {
var body: some View {
VStack(spacing: 0) {
- Rectangle().fill(.regularMaterial).frame(height: 28)
+ Rectangle()
+ .fill(Color.chatWindowBackgroundColor)
+ .scaledFrame(height: 28)
- Divider()
-
- ZStack {
- VStack(spacing: 0) {
- ChatBar(store: store, isChatHistoryVisible: $isChatHistoryVisible)
- .frame(height: 32)
- .background(Color(nsColor: .windowBackgroundColor))
-
- Divider()
-
- ChatTabContainer(store: store)
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- }
+ VStack(spacing: 0) {
+ ChatBar(store: store, isChatHistoryVisible: $isChatHistoryVisible)
+ .scaledFrame(height: 32)
+ .scaledPadding(.leading, 16)
+ .scaledPadding(.trailing, 8)
+
+ Divider()
+
+ ChatTabContainer(store: store)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
- .xcodeStyleFrame(cornerRadius: 10)
+ .xcodeStyleFrame()
.ignoresSafeArea(edges: .top)
}
}
@@ -89,21 +88,21 @@ struct ChatHistoryViewWrapper: View {
var body: some View {
WithPerceptionTracking {
VStack(spacing: 0) {
- Rectangle().fill(.regularMaterial).frame(height: 28)
-
- Divider()
+ Rectangle()
+ .fill(Color.chatWindowBackgroundColor)
+ .scaledFrame(height: 28)
ChatHistoryView(
store: store,
isChatHistoryVisible: $isChatHistoryVisible
)
- .background(Color(nsColor: .windowBackgroundColor))
+ .background(Color.chatWindowBackgroundColor)
.frame(
maxWidth: .infinity,
maxHeight: .infinity
)
}
- .xcodeStyleFrame(cornerRadius: 10)
+ .xcodeStyleFrame()
.ignoresSafeArea(edges: .top)
.preferredColorScheme(store.colorScheme)
.focusable()
@@ -133,10 +132,10 @@ struct ChatLoadingView: View {
Spacer()
}
- .xcodeStyleFrame(cornerRadius: 10)
+ .xcodeStyleFrame()
.ignoresSafeArea(edges: .top)
.frame(maxWidth: .infinity, maxHeight: .infinity)
- .background(Color(nsColor: .windowBackgroundColor))
+ .background(.ultraThinMaterial)
}
}
@@ -163,7 +162,7 @@ struct ChatTitleBar: View {
) {
Image(systemName: "minus")
.foregroundStyle(.black.opacity(0.5))
- .font(Font.system(size: 8).weight(.heavy))
+ .scaledFont(Font.system(size: 8).weight(.heavy))
}
.opacity(0)
.keyboardShortcut("m", modifiers: [.command])
@@ -181,7 +180,7 @@ struct ChatTitleBar: View {
) {
Image(systemName: "pin.fill")
.foregroundStyle(.black.opacity(0.5))
- .font(Font.system(size: 6).weight(.black))
+ .scaledFont(Font.system(size: 6).weight(.black))
.transformEffect(.init(translationX: 0, y: 0.5))
}
}
@@ -213,7 +212,7 @@ struct ChatTitleBar: View {
? color
: Color(nsColor: .separatorColor)
)
- .frame(
+ .scaledFrame(
width: Style.trafficLightButtonSize,
height: Style.trafficLightButtonSize
)
@@ -233,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
@@ -252,20 +248,26 @@ struct ChatBar: View {
var body: some View {
WithPerceptionTracking {
- HStack(spacing: 0) {
+ HStack(spacing: 8) {
if store.chatHistory.selectedWorkspaceName != nil {
ChatWindowHeader(store: store)
}
Spacer()
+ if statusObserver.quotaInfo != nil {
+ QuotaButton(store: store)
+
+ Divider()
+ .scaledFrame(height: 16)
+ }
+
CreateButton(store: store)
ChatHistoryButton(store: store, isChatHistoryVisible: $isChatHistoryVisible)
SettingsButton(store: store)
}
- .padding(.horizontal, 12)
}
}
@@ -322,13 +324,13 @@ struct ChatBar: View {
.resizable()
.renderingMode(.original)
.scaledToFit()
- .frame(width: 24, height: 24)
+ .scaledFrame(width: 24, height: 24)
Text(store.chatHistory.selectedWorkspaceName!)
- .font(.system(size: 13, weight: .bold))
- .padding(.leading, 4)
+ .scaledFont(size: 13, weight: .bold)
+ .scaledPadding(.leading, 4)
.truncationMode(.tail)
- .frame(maxWidth: 192, alignment: .leading)
+ .scaledFrame(maxWidth: 192, alignment: .leading)
.help(store.chatHistory.selectedWorkspacePath!)
}
}
@@ -344,9 +346,9 @@ struct ChatBar: View {
store.send(.createNewTapButtonClicked(kind: nil))
}) {
Image(systemName: "plus.bubble")
+ .scaledFont(.body)
}
.buttonStyle(HoverButtonStyle())
- .padding(.horizontal, 4)
.help("New Chat")
.accessibilityLabel("New Chat")
}
@@ -364,12 +366,13 @@ struct ChatBar: View {
}) {
if #available(macOS 15.0, *) {
Image(systemName: "clock.arrow.trianglehead.counterclockwise.rotate.90")
+ .scaledFont(.body)
} else {
Image(systemName: "clock.arrow.circlepath")
+ .scaledFont(.body)
}
}
.buttonStyle(HoverButtonStyle())
- .padding(.horizontal, 4)
.help("Show Chats...")
.accessibilityLabel("Show Chats...")
}
@@ -385,14 +388,173 @@ struct ChatBar: View {
store.send(.openSettings)
}) {
Image(systemName: "gearshape")
+ .scaledFont(.body)
}
.buttonStyle(HoverButtonStyle())
- .padding(.horizontal, 4)
.help("Open Settings")
.accessibilityLabel("Open Settings")
}
}
}
+
+ 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 {
@@ -506,7 +668,7 @@ struct CreateOtherChatTabMenuStyle: MenuStyle {
func makeBody(configuration: Configuration) -> some View {
Image(systemName: "chevron.down")
.resizable()
- .frame(width: 7, height: 4)
+ .scaledFrame(width: 7, height: 4)
.frame(maxHeight: .infinity)
.padding(.leading, 4)
.padding(.trailing, 8)
diff --git a/Core/Sources/SuggestionWidget/CodeReviewPanelView.swift b/Core/Sources/SuggestionWidget/CodeReviewPanelView.swift
index 1a64a7dc..f7359baa 100644
--- a/Core/Sources/SuggestionWidget/CodeReviewPanelView.swift
+++ b/Core/Sources/SuggestionWidget/CodeReviewPanelView.swift
@@ -53,7 +53,7 @@ struct CodeReviewPanelView: View {
.padding(.horizontal, 20)
.frame(maxWidth: .infinity, maxHeight: Style.codeReviewPanelHeight, alignment: .top)
.fixedSize(horizontal: false, vertical: true)
- .xcodeStyleFrame(cornerRadius: 10)
+ .xcodeStyleFrame()
.onAppear { viewStore.send(.appear) }
Spacer()
@@ -233,9 +233,8 @@ private struct CommentDetailView: View {
}
var fileNameView: some View {
- HStack(spacing: 8) {
+ HStack(alignment: .center, spacing: 8) {
drawFileIcon(fileURL)
- .resizable()
.scaledToFit()
.frame(width: 16, height: 16)
diff --git a/Core/Sources/SuggestionWidget/Extensions/Helper.swift b/Core/Sources/SuggestionWidget/Extensions/Helper.swift
index 06b55832..b59276f7 100644
--- a/Core/Sources/SuggestionWidget/Extensions/Helper.swift
+++ b/Core/Sources/SuggestionWidget/Extensions/Helper.swift
@@ -3,8 +3,16 @@ import AppKit
struct LocationStrategyHelper {
/// `lineNumber` is 0-based
- static func getLineFrame(_ lineNumber: Int, in editor: AXUIElement, with lines: [String]) -> CGRect? {
- guard editor.isSourceEditor,
+ ///
+ /// - Parameters:
+ /// - length: If specified, use this length instead of the actual line length. Useful when you want to get the exact line height and y that ignores the unwrappded lines.
+ static func getLineFrame(
+ _ lineNumber: Int,
+ in editor: AXUIElement,
+ with lines: [String],
+ length: Int? = nil
+ ) -> CGRect? {
+ guard editor.isNonNavigatorSourceEditor,
lineNumber < lines.count && lineNumber >= 0
else {
return nil
@@ -16,7 +24,15 @@ struct LocationStrategyHelper {
characterPosition += lines[i].count + 1
}
- var range = CFRange(location: characterPosition, length: lines[lineNumber].count)
+ let rangeLength: Int = {
+ if let length {
+ return min(length, lines[lineNumber].count)
+ } else {
+ return lines[lineNumber].count
+ }
+ }()
+
+ var range = CFRange(location: characterPosition, length: rangeLength)
guard let rangeValue = AXValueCreate(AXValueType.cfRange, &range) else {
return nil
}
diff --git a/Core/Sources/SuggestionWidget/Extensions/WidgetWindowsController+AgentConfigurationWidget.swift b/Core/Sources/SuggestionWidget/Extensions/WidgetWindowsController+AgentConfigurationWidget.swift
new file mode 100644
index 00000000..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+FixErrorPanel.swift b/Core/Sources/SuggestionWidget/Extensions/WidgetWindowsController+FixErrorPanel.swift
index 51f9b374..1dbf2aab 100644
--- a/Core/Sources/SuggestionWidget/Extensions/WidgetWindowsController+FixErrorPanel.swift
+++ b/Core/Sources/SuggestionWidget/Extensions/WidgetWindowsController+FixErrorPanel.swift
@@ -1,7 +1,14 @@
import AppKit
import XcodeInspector
+import Preferences
extension WidgetWindowsController {
+
+ @MainActor
+ var isFixErrorEnabled: Bool {
+ UserDefaults.shared.value(for: \.enableFixError)
+ }
+
@MainActor
func hideFixErrorWindow() {
windows.fixErrorPanelWindow.alphaValue = 0
@@ -16,27 +23,36 @@ extension WidgetWindowsController {
}
func setupFixErrorPanelObservers() {
- store.publisher
- .map(\.fixErrorPanelState.errorAnnotations)
- .removeDuplicates()
- .sink { [weak self] _ in
- Task { @MainActor [weak self] in
- await self?.updateFixErrorPanelWindowLocation()
+ Task { @MainActor in
+ let errorAnnotationsPublisher = store.publisher
+ .map(\.fixErrorPanelState.errorAnnotationsAtCursorPosition)
+ .removeDuplicates()
+ .sink { [weak self] _ in
+ Task { [weak self] in
+ await self?.updateFixErrorPanelWindowLocation()
+ }
}
- }.store(in: &cancellable)
-
- store.publisher
- .map(\.fixErrorPanelState.isPanelDisplayed)
- .removeDuplicates()
- .sink { [weak self ] _ in
- Task { @MainActor [weak self] in
- await self?.updateFixErrorPanelWindowLocation()
+
+ let isPanelDisplayedPublisher = store.publisher
+ .map(\.fixErrorPanelState.isPanelDisplayed)
+ .removeDuplicates()
+ .sink { [weak self ] _ in
+ Task { [weak self] in
+ await self?.updateFixErrorPanelWindowLocation()
+ }
}
- }.store(in: &cancellable)
+
+ await self.storeCancellables([errorAnnotationsPublisher, isPanelDisplayedPublisher])
+ }
}
@MainActor
func updateFixErrorPanelWindowLocation() async {
+ guard isFixErrorEnabled else {
+ hideFixErrorWindow()
+ return
+ }
+
guard let activeApp = await XcodeInspector.shared.safe.activeApplication,
(activeApp.isXcode || activeApp.isCopilotForXcodeExtensionService)
else {
@@ -69,12 +85,17 @@ extension WidgetWindowsController {
// Locate the window to the middle in Y
fixErrorPanelWindowFrame.origin.y = screen.frame.maxY - annotationRect.minY - annotationRect.height / 2 - fixErrorPanelWindowFrame.height / 2 + screen.frame.minY
- windows.fixErrorPanelWindow.setFrame(fixErrorPanelWindowFrame, display: true, animate: true)
+ windows.fixErrorPanelWindow.setFrame(fixErrorPanelWindowFrame, display: true, animate: false)
displayFixErrorWindow()
}
@MainActor
func handleFixErrorEditorNotification(notification: SourceEditor.AXNotification) async {
+ guard isFixErrorEnabled else {
+ hideFixErrorWindow()
+ return
+ }
+
switch notification.kind {
case .scrollPositionChanged:
store.send(.fixErrorPanel(.onScrollPositionChanged))
@@ -85,7 +106,5 @@ extension WidgetWindowsController {
default:
break
}
-
- await updateFixErrorPanelWindowLocation()
}
}
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/FixErrorPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/FixErrorPanelFeature.swift
index bd94029a..71850fa5 100644
--- a/Core/Sources/SuggestionWidget/FeatureReducers/FixErrorPanelFeature.swift
+++ b/Core/Sources/SuggestionWidget/FeatureReducers/FixErrorPanelFeature.swift
@@ -9,7 +9,7 @@ import ConversationTab
public struct FixErrorPanelFeature {
@ObservableState
public struct State: Equatable {
- public var focusedEditor: SourceEditor? = nil
+ public var focusedEditor: SourceEditor? = nil
public var editorContent: EditorInformation.SourceEditorContent? = nil
public var fixId: String? = nil
public var fixFailure: FixEditorErrorIssueFailure? = nil
@@ -17,22 +17,32 @@ public struct FixErrorPanelFeature {
editorContent?.cursorPosition
}
public var isPanelDisplayed: Bool = false
-
- public var errorAnnotations: [EditorInformation.LineAnnotation] {
- editorContent?.lineAnnotations.filter { $0.isError } ?? []
+ public var shouldCheckingAnnotations: Bool = false {
+ didSet {
+ if shouldCheckingAnnotations {
+ annotationCheckStartTime = Date()
+ }
+ }
}
+ public var maxCheckDuration: TimeInterval = 30.0
+ public var annotationCheckStartTime: Date? = nil
public var editorContentLines: [String] {
editorContent?.lines ?? []
}
public var errorAnnotationsAtCursorPosition: [EditorInformation.LineAnnotation] {
- let errorAnnotations = errorAnnotations
- guard !errorAnnotations.isEmpty, let cursorPosition = cursorPosition else {
+ guard let editorContent = editorContent else {
return []
}
- return errorAnnotations.filter { $0.line == cursorPosition.line + 1 }
+ return getErrorAnnotationsAtCursor(from: editorContent)
+ }
+
+ public func getErrorAnnotationsAtCursor(from editorContent: EditorInformation.SourceEditorContent) -> [EditorInformation.LineAnnotation] {
+ return editorContent.lineAnnotations
+ .filter { $0.isError }
+ .filter { $0.line == editorContent.cursorPosition.line + 1 }
}
public mutating func resetFailure() {
@@ -49,6 +59,7 @@ public struct FixErrorPanelFeature {
case fixErrorIssue([EditorInformation.LineAnnotation])
case scheduleFixFailureReset
+ case observeErrorNotification
case appear
case onFailure(FixEditorErrorIssueFailure)
@@ -58,6 +69,7 @@ public struct FixErrorPanelFeature {
// Annotation checking
case startAnnotationCheck
case onAnnotationCheckTimerFired
+ case stopCheckingAnnotation
}
let id = UUID()
@@ -76,6 +88,12 @@ public struct FixErrorPanelFeature {
Reduce { state, action in
switch action {
case .appear:
+ return .run { send in
+ await send(.observeErrorNotification)
+ await send(.startAnnotationCheck)
+ }
+
+ case .observeErrorNotification:
return .run { send in
let stream = AsyncStream { continuation in
let observer = NotificationCenter.default.addObserver(
@@ -105,35 +123,28 @@ public struct FixErrorPanelFeature {
id: CancelID.observeErrorNotification(id),
cancelInFlight: true
)
-
case .onFocusedEditorChanged(let editor):
state.focusedEditor = editor
- return .merge(
- .send(.startAnnotationCheck),
- .send(.resetFixFailure)
- )
+ state.editorContent = nil
+ state.shouldCheckingAnnotations = true
+ return .none
case .onEditorContentChanged:
- return .merge(
- .send(.startAnnotationCheck),
- .send(.resetFixFailure)
- )
+ state.shouldCheckingAnnotations = true
+ return .none
case .onScrollPositionChanged:
- return .merge(
- .send(.resetFixFailure),
- // Force checking the annotation
- .send(.onAnnotationCheckTimerFired),
- .send(.checkDisplay)
- )
+ if state.shouldCheckingAnnotations {
+ state.shouldCheckingAnnotations = false
+ }
+ if state.editorContent != nil {
+ state.editorContent = nil
+ }
+ return .none
case .onCursorPositionChanged:
- return .merge(
- .send(.resetFixFailure),
- // Force checking the annotation
- .send(.onAnnotationCheckTimerFired),
- .send(.checkDisplay)
- )
+ state.shouldCheckingAnnotations = true
+ return .none
case .fixErrorIssue(let annotations):
guard let fileURL = state.focusedEditor?.realtimeDocumentURL ?? nil,
@@ -200,11 +211,9 @@ public struct FixErrorPanelFeature {
case .startAnnotationCheck:
return .run { send in
- let startTime = Date()
- let maxDuration: TimeInterval = 60 * 5
- let interval: TimeInterval = 1
+ let interval: TimeInterval = 2
- while Date().timeIntervalSince(startTime) < maxDuration {
+ while !Task.isCancelled {
try await Task.sleep(nanoseconds: UInt64(interval) * 1_000_000_000)
await send(.onAnnotationCheckTimerFired)
@@ -212,27 +221,45 @@ public struct FixErrorPanelFeature {
}.cancellable(id: CancelID.annotationCheck(id), cancelInFlight: true)
case .onAnnotationCheckTimerFired:
- guard let editor = state.focusedEditor else {
- return .cancel(id: CancelID.annotationCheck(id))
+ // Check if max duration exceeded
+ if let startTime = state.annotationCheckStartTime,
+ Date().timeIntervalSince(startTime) > state.maxCheckDuration {
+ return .run { send in
+ await send(.stopCheckingAnnotation)
+ await send(.checkDisplay)
+ }
+ }
+
+ guard state.shouldCheckingAnnotations,
+ let editor = state.focusedEditor
+ else {
+ return .run { send in
+ await send(.checkDisplay)
+ }
}
let newEditorContent = editor.getContent()
- let newLineAnnotations = newEditorContent.lineAnnotations
- let newErrorLineAnnotations = newLineAnnotations.filter { $0.isError }
- let errorAnnotations = state.errorAnnotations
+ let newErrorAnnotationsAtCursorPosition = state.getErrorAnnotationsAtCursor(from: newEditorContent)
+ let errorAnnotationsAtCursorPosition = state.errorAnnotationsAtCursorPosition
if state.editorContent != newEditorContent {
state.editorContent = newEditorContent
}
- if errorAnnotations != newErrorLineAnnotations {
+ if Set(errorAnnotationsAtCursorPosition) != Set(newErrorAnnotationsAtCursorPosition) {
+ // Keep checking annotations as Xcode may update them asynchronously after content changes
return .merge(
- .send(.checkDisplay),
- .cancel(id: CancelID.annotationCheck(id))
+ .run { send in
+ await send(.checkDisplay)
+ }
)
} else {
return .none
}
+
+ case .stopCheckingAnnotation:
+ state.shouldCheckingAnnotations = false
+ return .none
}
}
}
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/FixErrorPanelView.swift b/Core/Sources/SuggestionWidget/FixErrorPanelView.swift
index 9381e2b6..0799b7a0 100644
--- a/Core/Sources/SuggestionWidget/FixErrorPanelView.swift
+++ b/Core/Sources/SuggestionWidget/FixErrorPanelView.swift
@@ -21,6 +21,7 @@ struct FixErrorPanelView: View {
let store: StoreOf
@State private var showFailurePopover = false
+ @Environment(\.colorScheme) var colorScheme
var body: some View {
WithViewStore(self.store, observe: ViewState.init) { viewStore in
@@ -49,7 +50,7 @@ struct FixErrorPanelView: View {
let annotations = viewStore.errorAnnotationsAtCursorPosition
let rect = annotations.first(where: { $0.rect != nil })?.rect ?? nil
let annotationHeight = rect?.height ?? 16
- let iconSize = annotationHeight * 0.8
+ let iconSize = annotationHeight * 0.7
Group {
if !annotations.isEmpty {
@@ -61,8 +62,14 @@ struct FixErrorPanelView: View {
.resizable()
.scaledToFit()
.frame(width: iconSize, height: iconSize)
+ .padding((annotationHeight - iconSize) / 2)
+ .foregroundColor(.white)
}
.buttonStyle(.plain)
+ .background(
+ RoundedRectangle(cornerRadius: 4)
+ .fill(Color("FixErrorBackgroundColor").opacity(0.8))
+ )
}
} else {
Color.clear
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 2c3dc199..6f063016 100644
--- a/Core/Sources/SuggestionWidget/Styles.swift
+++ b/Core/Sources/SuggestionWidget/Styles.swift
@@ -16,7 +16,9 @@ enum Style {
static let trafficLightButtonSize: Double = 12
static let codeReviewPanelWidth: Double = 550
static let codeReviewPanelHeight: Double = 450
- static let fixPanelToAnnotationSpacing: Double = 8
+ static let fixPanelToAnnotationSpacing: Double = 1
+ static let nesSuggestionMenuLeadingPadding: Double = 4
+ static let agentConfigurationWidgetLeadingSpacing: Double = 4
}
extension Color {
@@ -58,7 +60,7 @@ struct XcodeLikeFrame: View {
content.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
.background(
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
- .fill(Material.bar)
+ .fill(Color.chatWindowBackgroundColor)
)
.overlay(
RoundedRectangle(cornerRadius: max(0, cornerRadius), style: .continuous)
@@ -73,8 +75,9 @@ struct XcodeLikeFrame: View {
}
extension View {
- func xcodeStyleFrame(cornerRadius: Double? = nil) -> some View {
- XcodeLikeFrame(content: self, cornerRadius: cornerRadius ?? 10)
+ var xcodeStyleCornerRadius: Double { 16 }
+ func xcodeStyleFrame() -> some View {
+ XcodeLikeFrame(content: self, cornerRadius: xcodeStyleCornerRadius)
}
}
diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/ErrorPanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/ErrorPanel.swift
index b5791d17..2ef813dc 100644
--- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/ErrorPanel.swift
+++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/ErrorPanel.swift
@@ -1,4 +1,5 @@
import SwiftUI
+import SharedUIComponents
struct ErrorPanel: View {
var description: String
@@ -16,6 +17,7 @@ struct ErrorPanel: View {
// close button
Button(action: onCloseButtonTap) {
Image(systemName: "xmark")
+ .scaledFont(.body)
.padding([.leading, .bottom], 16)
.padding([.top, .trailing], 8)
.foregroundColor(.white)
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/SuggestionPanelContent/WarningPanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/WarningPanel.swift
index c06a915a..d5a1719e 100644
--- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/WarningPanel.swift
+++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/WarningPanel.swift
@@ -27,7 +27,7 @@ struct WarningPanel: View {
.renderingMode(.template)
.scaledToFit()
.foregroundColor(.primary)
- .frame(width: 14, height: 14)
+ .scaledFrame(width: 14, height: 14)
Text("Monthly completion limit reached.")
.font(.system(size: 12))
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 8f464efe..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,26 +97,42 @@ actor WidgetWindowsController: NSObject {
// Observe state change of fix error
setupFixErrorPanelObservers()
+
+ // Observer state change for NES
+ setupNESSuggestionPanelObservers()
+
+ // Observe feature flags
+ setupFeatureFlagObservers()
}
private func setupCodeReviewPanelObservers() {
- store.publisher
- .map(\.codeReviewPanelState.currentIndex)
- .removeDuplicates()
- .sink { [weak self] _ in
- Task { [weak self] in
- await self?.updateCodeReviewWindowLocation(.onCurrentReviewIndexChanged)
+ Task { @MainActor in
+ let currentIndexPublisher = store.publisher
+ .map(\.codeReviewPanelState.currentIndex)
+ .removeDuplicates()
+ .sink { [weak self] _ in
+ Task { [weak self] in
+ await self?.updateCodeReviewWindowLocation(.onCurrentReviewIndexChanged)
+ }
}
- }.store(in: &cancellable)
-
- store.publisher
- .map(\.codeReviewPanelState.isPanelDisplayed)
- .removeDuplicates()
- .sink { [weak self] isPanelDisplayed in
- Task { [weak self] in
- await self?.updateCodeReviewWindowLocation(.onIsPanelDisplayedChanged(isPanelDisplayed))
+
+ let isPanelDisplayedPublisher = store.publisher
+ .map(\.codeReviewPanelState.isPanelDisplayed)
+ .removeDuplicates()
+ .sink { [weak self] isPanelDisplayed in
+ Task { [weak self] in
+ await self?.updateCodeReviewWindowLocation(.onIsPanelDisplayedChanged(isPanelDisplayed))
+ }
}
- }.store(in: &cancellable)
+
+ await self.storeCancellables([currentIndexPublisher, isPanelDisplayedPublisher])
+ }
+ }
+
+ func storeCancellables(_ newCancellables: [AnyCancellable]) {
+ for cancellable in newCancellables {
+ self.cancellable.insert(cancellable)
+ }
}
}
@@ -223,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)
}
}
}
@@ -292,7 +291,7 @@ extension WidgetWindowsController {
@MainActor
func hideSuggestionPanelWindow() {
windows.suggestionPanelWindow.alphaValue = 0
- send(.panel(.hidePanel))
+ send(.panel(.hidePanel(.suggestion)))
}
@MainActor
@@ -308,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,
@@ -323,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:
@@ -331,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
@@ -352,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
@@ -369,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
}
@@ -390,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 {
@@ -401,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
@@ -424,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) {
@@ -465,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
@@ -488,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
@@ -508,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 {
@@ -528,7 +560,11 @@ extension WidgetWindowsController {
let currentXcodeRect = currentFocusedWindow.rect,
let notif = notif
else { return }
-
+
+ guard let sourceEditor = await xcodeInspector.safe.focusedEditor,
+ sourceEditor.realtimeWorkspaceURL != nil
+ else { return }
+
if let previousXcodeApp = (await previousXcodeApp),
currentXcodeApp.processIdentifier == previousXcodeApp.processIdentifier {
if currentFocusedWindow.isFullScreen == true {
@@ -581,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(
@@ -608,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`
@@ -622,6 +681,8 @@ extension WidgetWindowsController {
}
await adjustChatPanelWindowLevel()
+
+ await updateFixErrorPanelWindowLocation()
}
let now = Date()
@@ -783,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
}
@@ -792,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 }
@@ -993,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 = {
@@ -1044,7 +1191,7 @@ public final class WidgetWindows {
it.isOpaque = false
it.backgroundColor = .clear
it.collectionBehavior = [.fullScreenAuxiliary, .transient, .canJoinAllSpaces]
- it.hasShadow = true
+ it.hasShadow = false
it.level = widgetLevel(2)
it.contentView = NSHostingView(
rootView: FixErrorPanelView(
@@ -1054,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)
@@ -1118,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/Docs/Images/document-folder-permission-request.png b/Docs/Images/document-folder-permission-request.png
new file mode 100644
index 00000000..1d512ae4
Binary files /dev/null and b/Docs/Images/document-folder-permission-request.png differ
diff --git a/Docs/Images/screen-record-permission-request.png b/Docs/Images/screen-record-permission-request.png
new file mode 100644
index 00000000..0b3eeb25
Binary files /dev/null and b/Docs/Images/screen-record-permission-request.png differ
diff --git a/EditorExtension/AcceptNESSuggestionCommand.swift b/EditorExtension/AcceptNESSuggestionCommand.swift
new file mode 100644
index 00000000..6c015030
--- /dev/null
+++ b/EditorExtension/AcceptNESSuggestionCommand.swift
@@ -0,0 +1,32 @@
+import Client
+import SuggestionBasic
+import Foundation
+import XcodeKit
+import XPCShared
+
+class AcceptNESSuggestionCommand: NSObject, XCSourceEditorCommand, CommandType {
+ var name: String { "Accept Next Edit Suggestion" }
+
+ func perform(
+ with invocation: XCSourceEditorCommandInvocation,
+ completionHandler: @escaping (Error?) -> Void
+ ) {
+ Task {
+ do {
+ try await (Task(timeout: 7) {
+ let service = try getService()
+ if let content = try await service.getNESSuggestionAcceptedCode(
+ editorContent: .init(invocation)
+ ) {
+ invocation.accept(content)
+ }
+ completionHandler(nil)
+ }.value)
+ } catch is CancellationError {
+ completionHandler(nil)
+ } catch {
+ completionHandler(error)
+ }
+ }
+ }
+}
diff --git a/EditorExtension/Info.plist b/EditorExtension/Info.plist
index 13a9bdb6..b6fa3b60 100644
--- a/EditorExtension/Info.plist
+++ b/EditorExtension/Info.plist
@@ -44,5 +44,7 @@
$(TeamIdentifierPrefix)STANDARD_TELEMETRY_CHANNEL_KEY$(STANDARD_TELEMETRY_CHANNEL_KEY)
+ GITHUB_APP_ID
+ $(GITHUB_APP_ID)
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 7f89e6cf..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
}
@@ -300,61 +313,64 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
// If the quota is nil, keep the original auth status item
// Else only log the CLS error other than quota limit reached error
if CLSMessageSummary.summary == CLSMessageType.other.summary || status.quotaInfo == nil {
- self.authStatusItem.isHidden = false
- self.authStatusItem.title = CLSMessageSummary.summary
-
- let submenu = NSMenu()
- let attributedCLSErrorItem = NSMenuItem()
- attributedCLSErrorItem.view = ErrorMessageView(
- errorMessage: CLSMessageSummary.detail
+ configureCLSAuthStatusItem(
+ summary: CLSMessageSummary,
+ actionTitle: "View Details on GitHub",
+ action: #selector(openGitHubDetailsLink)
)
- submenu.addItem(attributedCLSErrorItem)
- submenu.addItem(.separator())
- submenu.addItem(
- NSMenuItem(
- title: "View Details on GitHub",
- action: #selector(openGitHubDetailsLink),
- keyEquivalent: ""
- )
+ } else if CLSMessageSummary.summary == CLSMessageType.byokLimitedReached.summary {
+ configureCLSAuthStatusItem(
+ summary: CLSMessageSummary,
+ actionTitle: "Dismiss",
+ action: #selector(dismissBYOKMessage)
)
-
- self.authStatusItem.submenu = submenu
- self.authStatusItem.isEnabled = true
+ } else {
+ // Explicitly hide to avoid leaving stale content if another CLS state was previously shown.
+ self.authStatusItem.isHidden = true
}
} else {
self.authStatusItem.isHidden = true
}
- 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
}
+ func configureCLSAuthStatusItem(
+ summary: CLSMessage,
+ actionTitle: String,
+ action: Selector
+ ) {
+ self.authStatusItem.isHidden = false
+ self.authStatusItem.title = summary.summary
+ let submenu = NSMenu()
+
+ let attributedCLSErrorItem = NSMenuItem()
+ attributedCLSErrorItem.view = ErrorMessageView(
+ errorMessage: summary.detail
+ )
+ submenu.addItem(attributedCLSErrorItem)
+ submenu.addItem(.separator())
+ submenu.addItem(
+ NSMenuItem(
+ title: actionTitle,
+ action: action,
+ keyEquivalent: ""
+ )
+ )
+ self.authStatusItem.submenu = submenu
+ self.authStatusItem.isEnabled = true
+ }
+
private func configureNotAuthorized(status: StatusResponse) {
self.accountItem.view = AccountItemView(
target: self,
@@ -378,6 +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
}
@@ -391,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
}
@@ -477,6 +495,12 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
}
}
}
+
+ @objc func dismissBYOKMessage() {
+ Task {
+ await Status.shared.updateCLSStatus(.normal, busy: false, message: "")
+ }
+ }
}
extension NSRunningApplication {
@@ -491,6 +515,8 @@ extension NSRunningApplication {
enum CLSMessageType {
case chatLimitReached
case completionLimitReached
+ case byokLimitedReached
+ case monthlyAICreditsLimitReached
case other
var summary: String {
@@ -499,6 +525,10 @@ enum CLSMessageType {
return "Monthly Chat Limit Reached"
case .completionLimitReached:
return "Monthly Completion Limit Reached"
+ case .byokLimitedReached:
+ return "BYOK Limit Reached"
+ case .monthlyAICreditsLimitReached:
+ return "Monthly AI Credits Limit Reached"
case .other:
return "CLS Error"
}
@@ -510,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
@@ -526,16 +548,13 @@ func getCLSMessageSummary(_ message: String) -> CLSMessage {
messageType = .chatLimitReached
} else if message.contains("Completions limit reached") {
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/ChatWindowBackgroundColor.colorset/Contents.json b/ExtensionService/Assets.xcassets/ChatWindowBackgroundColor.colorset/Contents.json
new file mode 100644
index 00000000..1ff6f10e
--- /dev/null
+++ b/ExtensionService/Assets.xcassets/ChatWindowBackgroundColor.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0xFB",
+ "green" : "0xFB",
+ "red" : "0xFB"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0x24",
+ "green" : "0x24",
+ "red" : "0x24"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ExtensionService/Assets.xcassets/ChatWindowEditorBackgroundColor.colorset/Contents.json b/ExtensionService/Assets.xcassets/ChatWindowEditorBackgroundColor.colorset/Contents.json
new file mode 100644
index 00000000..a68c9465
--- /dev/null
+++ b/ExtensionService/Assets.xcassets/ChatWindowEditorBackgroundColor.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0xFF",
+ "green" : "0xFF",
+ "red" : "0xFF"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0x30",
+ "green" : "0x2A",
+ "red" : "0x29"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ExtensionService/Assets.xcassets/Colors/AgentToolStatusDividerColor.colorset/Contents.json b/ExtensionService/Assets.xcassets/Colors/AgentToolStatusDividerColor.colorset/Contents.json
new file mode 100644
index 00000000..8ea26959
--- /dev/null
+++ b/ExtensionService/Assets.xcassets/Colors/AgentToolStatusDividerColor.colorset/Contents.json
@@ -0,0 +1,33 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0xF0",
+ "green" : "0xEC",
+ "red" : "0xEB"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "platform" : "universal",
+ "reference" : "secondaryLabelColor"
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ExtensionService/Assets.xcassets/Colors/AgentToolStatusOutlineColor.colorset/Contents.json b/ExtensionService/Assets.xcassets/Colors/AgentToolStatusOutlineColor.colorset/Contents.json
new file mode 100644
index 00000000..58029746
--- /dev/null
+++ b/ExtensionService/Assets.xcassets/Colors/AgentToolStatusOutlineColor.colorset/Contents.json
@@ -0,0 +1,33 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0xE5",
+ "green" : "0xE1",
+ "red" : "0xDF"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "platform" : "universal",
+ "reference" : "secondaryLabelColor"
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ExtensionService/Assets.xcassets/Colors/Contents.json b/ExtensionService/Assets.xcassets/Colors/Contents.json
new file mode 100644
index 00000000..73c00596
--- /dev/null
+++ b/ExtensionService/Assets.xcassets/Colors/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ExtensionService/Assets.xcassets/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/DarkBlue.colorset/Contents.json b/ExtensionService/Assets.xcassets/DarkBlue.colorset/Contents.json
new file mode 100644
index 00000000..9658cfff
--- /dev/null
+++ b/ExtensionService/Assets.xcassets/DarkBlue.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "248",
+ "green" : "189",
+ "red" : "160"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "85",
+ "green" : "45",
+ "red" : "25"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ExtensionService/Assets.xcassets/EditSparkle.imageset/Contents.json b/ExtensionService/Assets.xcassets/EditSparkle.imageset/Contents.json
new file mode 100644
index 00000000..c66c7fae
--- /dev/null
+++ b/ExtensionService/Assets.xcassets/EditSparkle.imageset/Contents.json
@@ -0,0 +1,16 @@
+{
+ "images" : [
+ {
+ "filename" : "edit-sparkle.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "preserves-vector-representation" : true,
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/ExtensionService/Assets.xcassets/EditSparkle.imageset/edit-sparkle.svg b/ExtensionService/Assets.xcassets/EditSparkle.imageset/edit-sparkle.svg
new file mode 100644
index 00000000..9cff22ff
--- /dev/null
+++ b/ExtensionService/Assets.xcassets/EditSparkle.imageset/edit-sparkle.svg
@@ -0,0 +1,12 @@
+
diff --git a/ExtensionService/Assets.xcassets/FixErrorBackgroundColor.colorset/Contents.json b/ExtensionService/Assets.xcassets/FixErrorBackgroundColor.colorset/Contents.json
new file mode 100644
index 00000000..57288f5d
--- /dev/null
+++ b/ExtensionService/Assets.xcassets/FixErrorBackgroundColor.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "48",
+ "green" : "59",
+ "red" : "255"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "58",
+ "green" : "69",
+ "red" : "255"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ExtensionService/Assets.xcassets/IconStrokeColor.colorset/Contents.json b/ExtensionService/Assets.xcassets/IconStrokeColor.colorset/Contents.json
new file mode 100644
index 00000000..dbc3a4e3
--- /dev/null
+++ b/ExtensionService/Assets.xcassets/IconStrokeColor.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "display-p3",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0x7E",
+ "green" : "0x70",
+ "red" : "0x6C"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "display-p3",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0xD6",
+ "green" : "0xD0",
+ "red" : "0xCE"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ExtensionService/Assets.xcassets/LightBlue.colorset/Contents.json b/ExtensionService/Assets.xcassets/LightBlue.colorset/Contents.json
new file mode 100644
index 00000000..552c7769
--- /dev/null
+++ b/ExtensionService/Assets.xcassets/LightBlue.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0xFF",
+ "green" : "0xE2",
+ "red" : "0xD4"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "110",
+ "green" : "67",
+ "red" : "46"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ExtensionService/Assets.xcassets/LightBluePrimary.colorset/Contents.json b/ExtensionService/Assets.xcassets/LightBluePrimary.colorset/Contents.json
new file mode 100644
index 00000000..1c8b1e91
--- /dev/null
+++ b/ExtensionService/Assets.xcassets/LightBluePrimary.colorset/Contents.json
@@ -0,0 +1,20 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0xF0",
+ "green" : "0x74",
+ "red" : "0x35"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ExtensionService/Assets.xcassets/LightGreen.colorset/Contents.json b/ExtensionService/Assets.xcassets/LightGreen.colorset/Contents.json
new file mode 100644
index 00000000..ef4b486e
--- /dev/null
+++ b/ExtensionService/Assets.xcassets/LightGreen.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "60",
+ "green" : "138",
+ "red" : "32"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0x3C",
+ "green" : "0x8A",
+ "red" : "0x20"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ExtensionService/Assets.xcassets/NESShadowColor.colorset/Contents.json b/ExtensionService/Assets.xcassets/NESShadowColor.colorset/Contents.json
new file mode 100644
index 00000000..f606b54c
--- /dev/null
+++ b/ExtensionService/Assets.xcassets/NESShadowColor.colorset/Contents.json
@@ -0,0 +1,20 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "0.180",
+ "blue" : "0x26",
+ "green" : "0x1F",
+ "red" : "0x1B"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ExtensionService/Assets.xcassets/Sparkle.imageset/Contents.json b/ExtensionService/Assets.xcassets/Sparkle.imageset/Contents.json
index db53bbf8..2f1e961f 100644
--- a/ExtensionService/Assets.xcassets/Sparkle.imageset/Contents.json
+++ b/ExtensionService/Assets.xcassets/Sparkle.imageset/Contents.json
@@ -18,5 +18,8 @@
"info" : {
"author" : "xcode",
"version" : 1
+ },
+ "properties" : {
+ "preserves-vector-representation" : true
}
}
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/Assets.xcassets/codeReview.imageset/Contents.json b/ExtensionService/Assets.xcassets/codeReview.imageset/Contents.json
index ddb0a503..a1548aa0 100644
--- a/ExtensionService/Assets.xcassets/codeReview.imageset/Contents.json
+++ b/ExtensionService/Assets.xcassets/codeReview.imageset/Contents.json
@@ -1,7 +1,7 @@
{
"images" : [
{
- "filename" : "codeReview.svg",
+ "filename" : "code-review-light.svg",
"idiom" : "universal"
},
{
@@ -11,7 +11,7 @@
"value" : "dark"
}
],
- "filename" : "codeReview 1.svg",
+ "filename" : "code-review-dark.svg",
"idiom" : "universal"
}
],
@@ -20,6 +20,7 @@
"version" : 1
},
"properties" : {
- "preserves-vector-representation" : true
+ "preserves-vector-representation" : true,
+ "template-rendering-intent" : "template"
}
}
diff --git a/ExtensionService/Assets.xcassets/codeReview.imageset/code-review-dark.svg b/ExtensionService/Assets.xcassets/codeReview.imageset/code-review-dark.svg
new file mode 100644
index 00000000..39eea110
--- /dev/null
+++ b/ExtensionService/Assets.xcassets/codeReview.imageset/code-review-dark.svg
@@ -0,0 +1,10 @@
+
diff --git a/ExtensionService/Assets.xcassets/codeReview.imageset/code-review-light.svg b/ExtensionService/Assets.xcassets/codeReview.imageset/code-review-light.svg
new file mode 100644
index 00000000..b60a0982
--- /dev/null
+++ b/ExtensionService/Assets.xcassets/codeReview.imageset/code-review-light.svg
@@ -0,0 +1,10 @@
+
diff --git a/ExtensionService/Assets.xcassets/codeReview.imageset/codeReview 1.svg b/ExtensionService/Assets.xcassets/codeReview.imageset/codeReview 1.svg
deleted file mode 100644
index 44ce60ee..00000000
--- a/ExtensionService/Assets.xcassets/codeReview.imageset/codeReview 1.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
diff --git a/ExtensionService/Assets.xcassets/codeReview.imageset/codeReview.svg b/ExtensionService/Assets.xcassets/codeReview.imageset/codeReview.svg
deleted file mode 100644
index 6084e72c..00000000
--- a/ExtensionService/Assets.xcassets/codeReview.imageset/codeReview.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
diff --git a/ExtensionService/Info.plist b/ExtensionService/Info.plist
index 19f114ff..f7f84340 100644
--- a/ExtensionService/Info.plist
+++ b/ExtensionService/Info.plist
@@ -29,5 +29,7 @@
$(COPILOT_FORUM_URL)STANDARD_TELEMETRY_CHANNEL_KEY$(STANDARD_TELEMETRY_CHANNEL_KEY)
+ GITHUB_APP_ID
+ $(GITHUB_APP_ID)
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 3a34e1c1..3c922d4a 100644
--- a/ReleaseNotes.md
+++ b/ReleaseNotes.md
@@ -1,20 +1,13 @@
-### GitHub Copilot for Xcode 0.42.0
+### GitHub Copilot for Xcode 0.50.0
**๐ Highlights**
-* Support for Bring Your Own Keys (BYOK) with model providers including Azure, OpenAI, Anthropic, Gemini, Groq, and OpenRouter. See [BYOK.md](https://github.com/github/CopilotForXcode/blob/0.42.0/Docs/BYOK.md).
-* Support for custom instruction files at `.github/instructions/*.instructions.md`. See [CustomInstructions.md](https://github.com/github/CopilotForXcode/blob/0.42.0/Docs/CustomInstructions.md).
-* Support for prompt files at `.github/prompts/*.prompt.md`. See [PromptFiles.md](https://github.com/github/CopilotForXcode/blob/0.42.0/Docs/PromptFiles.md).
-* Default chat mode is now set to โAgentโ.
+- **Reasoning Effort**: Control how deeply reasoning-capable models think before responding. You can now select reasoning efforts directly from the model picker for supported models, letting you balance response speed against answer quality.
+- **Bring Your Own Key (BYOK) is now Generally Available**: Bring Your Own Key support has graduated from preview and is now available to all users. Configure your own API keys for third-party models directly in Copilot for Xcode settings.
-**๐ช Improvements**
+**๐ช Changes**
-* Use the current selection as chat context.
-* Add folders as chat context.
-* Shortcut to quickly fix errors in Xcode.
-* Use โ/โ keys to reuse previous chat context in the chat view.
+- Added internal support for upcoming [usage-based billing](https://github.blog/news-insights/company-news/github-copilot-is-moving-to-usage-based-billing/), including billing updates for the usage panel, usage notifications, and model picker. This will be visible once usage-based billing rolls out.
-**๐ ๏ธ Bug Fixes**
-
-* Cannot copy url from Safari browser to chat view.
+ 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 a823aae8..5a375b85 100644
--- a/Server/package-lock.json
+++ b/Server/package-lock.json
@@ -8,18 +8,20 @@
"name": "@github/copilot-xcode",
"version": "0.0.1",
"dependencies": {
- "@github/copilot-language-server": "^1.362.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.0",
+ "copy-webpack-plugin": "^14.0.0",
"css-loader": "^7.1.2",
"style-loader": "^4.0.0",
- "terser-webpack-plugin": "^5.3.14",
- "ts-loader": "^9.5.2",
+ "terser-webpack-plugin": "^5.4.0",
+ "ts-loader": "^9.5.4",
"typescript": "^5.8.3",
"webpack": "^5.99.9",
"webpack-cli": "^6.0.1"
@@ -36,16 +38,100 @@
}
},
"node_modules/@github/copilot-language-server": {
- "version": "1.362.0",
- "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.362.0.tgz",
- "integrity": "sha512-4lBt/nVRGCiOZx1Btuvp043ZhGqb3/o8aZzG564nOTOgy3MtQfsNCwzbci+7hZXEuRIPM4bZjBqIzTZ2S2nu9Q==",
+ "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"
},
"bin": {
"copilot-language-server": "dist/language-server.js"
- }
+ },
+ "optionalDependencies": {
+ "@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.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"
+ ],
+ "license": "MIT",
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@github/copilot-language-server-darwin-x64": {
+ "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"
+ ],
+ "license": "MIT",
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@github/copilot-language-server-linux-arm64": {
+ "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"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@github/copilot-language-server-linux-x64": {
+ "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"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "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.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"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.8",
@@ -635,20 +721,20 @@
"license": "MIT"
},
"node_modules/copy-webpack-plugin": {
- "version": "13.0.0",
- "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-13.0.0.tgz",
- "integrity": "sha512-FgR/h5a6hzJqATDGd9YG41SeDViH+0bkHn6WNXCi5zKAZkeESeSxLySSsFLHqLEVCh0E+rITmCf0dusXWYukeQ==",
+ "version": "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",
@@ -838,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": [
{
@@ -1478,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",
@@ -1555,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",
@@ -1610,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": {
@@ -1762,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": {
@@ -1827,9 +1881,9 @@
}
},
"node_modules/ts-loader": {
- "version": "9.5.2",
- "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.5.2.tgz",
- "integrity": "sha512-Qo4piXvOTWcMGIgRiuFa6nHNm+54HbYaZCKqc9eeZCLRy3XqafQgwX2F7mofrbJG3g7EEb+lkiR+z2Lic2s3Zw==",
+ "version": "9.5.4",
+ "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.5.4.tgz",
+ "integrity": "sha512-nCz0rEwunlTZiy6rXFByQU1kVVpCIgUpc/psFiKVrUwrizdnIbRFu8w7bxhUF0X613DYwT4XzrZHpVyMe758hQ==",
"dev": true,
"license": "MIT",
"dependencies": {
diff --git a/Server/package.json b/Server/package.json
index b434a35f..bd713687 100644
--- a/Server/package.json
+++ b/Server/package.json
@@ -7,18 +7,20 @@
"build": "webpack"
},
"dependencies": {
- "@github/copilot-language-server": "^1.362.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.0",
+ "copy-webpack-plugin": "^14.0.0",
"css-loader": "^7.1.2",
"style-loader": "^4.0.0",
- "terser-webpack-plugin": "^5.3.14",
- "ts-loader": "^9.5.2",
+ "terser-webpack-plugin": "^5.4.0",
+ "ts-loader": "^9.5.4",
"typescript": "^5.8.3",
"webpack": "^5.99.9",
"webpack-cli": "^6.0.1"
diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md
index 68be3768..eee16478 100644
--- a/TROUBLESHOOTING.md
+++ b/TROUBLESHOOTING.md
@@ -12,6 +12,8 @@ common issues:
- [Extension Permission](#extension-permission) - Allows GitHub Copilot to integrate with Xcode
- [Accessibility Permission](#accessibility-permission) - Enables real-time code suggestions
- [Background Permission](#background-permission) - Allows extension to connect with host app
+ - [Files & Folders Permission](#files--folders-permission) - Allows GitHub Copilot for Xcode to access files and folders
+ - [Screen & System Audio Recording Permission](#screen--system-audio-recording-permission-optional) (Optional) - Allow GitHub Copilot for Xcode to capture screen when using Copilot Vision
Please note that GitHub Copilot for Xcode may not work properly if any necessary permissions are missing.
@@ -30,7 +32,8 @@ Or you can navigate to the permission manually depending on your OS version:
| macOS | Location |
| :--- | :--- |
-| 15 | System Settings > General > Login Items > Extensions > Xcode Source Editor |
+| 26 | System Settings > General > Login Items & Extensions > Extensions > By Category > Xcode Source Editor |
+| 15 | System Settings > General > Login Items & Extensions > Extensions > Xcode Source Editor |
| 13 & 14 | System Settings > Privacy & Security > Extensions > Xcode Source Editor |
| 12 | System Preferences > Extensions |
@@ -74,15 +77,57 @@ Please ensure that this permission is enabled. You can manually navigate to the
| macOS | Location |
| :--- | :--- |
+| 26 | System Settings > General > Login Items & Extensions > App Background Activity |
| 15 | System Settings > General > Login Items & Extensions > Allow in the Background |
| 13 & 14 | System Settings > General > Login Items > Allow in the Background |
Ensure that "GitHub Copilot for Xcode" is enabled in the list of allowed background items. Without this permission, the extension may not be able to properly communicate with the host app, which can result in inconsistent behavior or reduced functionality.
+## Files & Folders Permission
+
+GitHub Copilot for Xcode needs permission to read your projectโs files so it can:
+
+- Use actual file contents as contextual grounding when you ask questions in Ask and Agent mode (instead of generic language-only answers)
+- Safely apply or preview multi-file edits in Agent modes (e.g. refactors, adding tests, updating related types)
+- Improve precision by leveraging nearby code, patterns, and naming conventions
+
+
+
+
+
+When first prompted macOS shows a dialog asking to allow access to folders. Click "Allow".
+If you clicked "Don't Allow" or nothing appears:
+
+| macOS | Location |
+| :--- | :--- |
+| 13 & 14 & 15 & 26 | System Settings > Privacy & Security > Files and Folders |
+| 12 | System Preferences > Security & Privacy > Privacy > Files and Folders |
+
+In the list, expand `GitHub Copilot for Xcode` and enable the toggles for any relevant locations (e.g. โDocumentsโ if your repositories live there). If your code is elsewhere (e.g. `~/Developer`), macOS may instead prompt dynamically the next time Copilot tries to read those pathsโaccept when prompted.
+
+## Screen & System Audio Recording Permission (Optional)
+
+This permission is only needed if you choose to use Copilot Vision (screen-based context capture).
+
+Copilot does NOT require screen recording for standard inline suggestions, chat, or agent operations.
+
+
+
+
+
+This permission is typically granted automatically when you first use Copilot Vision and try to capture screen in GitHub Copilot for Xcode. You can also manually navigate to the background permission setting based on your macOS version:
+
+| macOS | Location |
+| :--- | :--- |
+| 14 & 15 & 26 | System Settings > Privacy & Security > Screen & System Audio Recording |
+| 13 | System Settings > Privacy & Security > Screen Recording |
+| 12 | System Preferences > Security & Privacy > Privacy > Screen Recording |
+
+Check `GitHub Copilot for Xcode` and restart the app.
## Logs
-Logs can be found in `~/Library/Logs/GitHubCopilot/` the most recent log file
+Logs can be found in `~/Library/Logs/GitHubCopilot/`. The most recent log file
is:
```
diff --git a/Tool/Package.swift b/Tool/Package.swift
index 7ebbf859..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"
@@ -256,7 +261,7 @@ let package = Package(
.target(
name: "Status",
- dependencies: ["Cache"]
+ dependencies: ["Cache", "Preferences"]
),
.target(
@@ -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"),
]
@@ -363,7 +374,7 @@ let package = Package(
// MARK: - AppKitExtension
- .target(name: "AppKitExtension"),
+ .target(name: "AppKitExtension", dependencies: ["Logger"]),
// MARK: - GitHelper
.target(
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 0b62e141..fbf5852b 100644
--- a/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift
+++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift
@@ -56,36 +56,53 @@ public final class BuiltinExtensionConversationServiceProvider<
}
}
- public func createConversation(_ request: ConversationRequest, workspaceURL: URL?) async throws {
+ public func createConversation(
+ _ request: ConversationRequest, workspaceURL: URL?
+ ) async throws -> ConversationCreateResponse? {
guard let conversationService else {
Logger.service.error("Builtin chat service not found.")
- return
+ return nil
}
guard let workspaceInfo = await activeWorkspace(workspaceURL) else {
Logger.service.error("Could not get active workspace info")
- return
+ return nil
}
- try await conversationService.createConversation(request, workspace: workspaceInfo)
+ return try await conversationService.createConversation(request, workspace: workspaceInfo)
}
- public func createTurn(with conversationId: String, request: ConversationRequest, workspaceURL: URL?) async throws {
+ public func createTurn(
+ with conversationId: String, request: ConversationRequest, workspaceURL: URL?
+ ) async throws -> ConversationCreateResponse? {
guard let conversationService else {
Logger.service.error("Builtin chat service not found.")
- return
+ return nil
}
guard let workspaceInfo = await activeWorkspace(workspaceURL) else {
Logger.service.error("Could not get active workspace info")
- return
+ return nil
}
- try await conversationService
+ return try await conversationService
.createTurn(
with: conversationId,
request: request,
workspace: workspaceInfo
)
}
+
+ public func deleteTurn(with conversationId: String, turnId: String, workspaceURL: URL?) async throws {
+ guard let conversationService else {
+ Logger.service.error("Builtin chat service not found.")
+ return
+ }
+ guard let workspaceInfo = await activeWorkspace(workspaceURL) else {
+ Logger.service.error("Could not get active workspace info")
+ return
+ }
+
+ try await conversationService.deleteTurn(with: conversationId, turnId: turnId, workspace: workspaceInfo)
+ }
public func stopReceivingMessage(_ workDoneToken: String, workspaceURL: URL?) async throws {
guard let conversationService else {
@@ -136,6 +153,19 @@ public final class BuiltinExtensionConversationServiceProvider<
return (try? await conversationService.templates(workspace: workspaceInfo))
}
+
+ public func modes() async throws -> [ConversationMode]? {
+ guard let conversationService else {
+ Logger.service.error("Builtin chat service not found.")
+ return nil
+ }
+ guard let workspaceInfo = await activeWorkspace() else {
+ Logger.service.error("Could not get active workspace info")
+ return nil
+ }
+
+ return (try? await conversationService.modes(workspace: workspaceInfo))
+ }
public func models() async throws -> [CopilotModel]? {
guard let conversationService else {
@@ -172,7 +202,7 @@ public final class BuiltinExtensionConversationServiceProvider<
return (try? await conversationService.agents(workspace: workspaceInfo))
}
- public func reviewChanges(_ params: ReviewChangesParams) async throws -> CodeReviewResult? {
+ public func reviewChanges(_ changes: [ReviewChangesParams.Change]) async throws -> CodeReviewResult? {
guard let conversationService else {
Logger.service.error("Builtin chat service not found.")
return nil
@@ -181,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, params: params))
+
+ 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 9bcbcf97..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)
@@ -26,6 +93,15 @@ public extension ChatMemory {
$0.removeAll { $0.id == id }
}
}
+
+ /// Remove multiple messages from the history by their IDs.
+ func removeMessages(_ ids: [String]) async {
+ await mutateHistory { history in
+ history.removeAll { message in
+ ids.contains(message.id)
+ }
+ }
+ }
/// Clear the history.
func clearHistory() async {
@@ -35,26 +111,19 @@ public extension ChatMemory {
extension ChatMessage {
mutating func mergeMessage(with message: ChatMessage) {
- // merge content
self.content = self.content + message.content
- // merge references
var seen = Set()
- // without duplicated and keep order
self.references = (self.references + message.references).filter { seen.insert($0).inserted }
- // merge followUp
self.followUp = message.followUp ?? self.followUp
- // merge suggested title
self.suggestedTitle = message.suggestedTitle ?? self.suggestedTitle
- // merge error message
self.errorMessages = self.errorMessages + message.errorMessages
self.panelMessages = self.panelMessages + message.panelMessages
- // merge steps
if !message.steps.isEmpty {
var mergedSteps = self.steps
@@ -68,18 +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
+
+ self.fileEdits = mergeFileEdits(oldEdits: self.fileEdits, newEdits: message.fileEdits)
+
+ self.turnStatus = message.turnStatus ?? self.turnStatus
+
+ // merge modelName and billingMultiplier
+ self.modelName = message.modelName ?? self.modelName
+ self.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] {
@@ -88,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
}
@@ -109,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)
}
@@ -116,4 +254,52 @@ extension ChatMessage {
return mergedAgentRounds
}
+
+ private func mergeFileEdits(oldEdits: [FileEdit], newEdits: [FileEdit]) -> [FileEdit] {
+ var edits = oldEdits
+
+ for newEdit in newEdits {
+ if let index = edits.firstIndex(
+ where: { $0.fileURL == newEdit.fileURL && $0.toolName == newEdit.toolName }
+ ) {
+ edits[index].modifiedContent = newEdit.modifiedContent
+ edits[index].status = newEdit.status
+ } else {
+ edits.append(newEdit)
+ }
+ }
+
+ return edits
+ }
+}
+
+/// 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 0da9335c..868a13b3 100644
--- a/Tool/Sources/ChatAPIService/Models.swift
+++ b/Tool/Sources/ChatAPIService/Models.swift
@@ -3,6 +3,37 @@ import Foundation
import ConversationServiceProvider
import GitHubCopilotService
+public struct FileEdit: Equatable, Codable {
+
+ public enum Status: String, Codable {
+ case none = "none"
+ case kept = "kept"
+ case undone = "undone"
+ }
+
+ public let fileURL: URL
+ public let originalContent: String
+ public var modifiedContent: String
+ public var status: Status
+
+ /// Different toolName, the different undo logic. Like `insert_edit_into_file` and `create_file`
+ public var toolName: ToolName
+
+ public init(
+ fileURL: URL,
+ originalContent: String,
+ modifiedContent: String,
+ status: Status = .none,
+ toolName: ToolName
+ ) {
+ self.fileURL = fileURL
+ self.originalContent = originalContent
+ self.modifiedContent = modifiedContent
+ self.status = status
+ self.toolName = toolName
+ }
+}
+
// move here avoid circular reference
public struct ConversationReference: Codable, Equatable, Hashable {
public enum Kind: Codable, Equatable, Hashable {
@@ -71,6 +102,13 @@ public struct ConversationReference: Codable, Equatable, Hashable {
}
+public enum RequestType: String, Equatable, Codable {
+ case conversation, codeReview
+}
+
+public let HardCodedToolRoundExceedErrorMessage: String = "Oops, maximum tool attempts reached. You can update the max tool requests in settings."
+public let SSLCertificateErrorMessage: String = "Unable to verify the SSL certificate. This often happens in enterprise environments with custom certificates. Try disabling **Proxy strict SSL** in the Proxy Settings."
+
public struct ChatMessage: Equatable, Codable {
public typealias ID = String
@@ -78,6 +116,12 @@ public struct ChatMessage: Equatable, Codable {
case user
case assistant
case system
+
+ public var isAssistant: Bool { self == .assistant }
+ }
+
+ public enum TurnStatus: String, Codable, Equatable {
+ case inProgress, success, cancelled, error, waitForConfirmation
}
/// The role of a message.
@@ -114,13 +158,32 @@ 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?
+ /// File edits performed during the current conversation turn.
+ /// Used as a checkpoint to track file modifications made by tools.
+ /// Note: Status changes (kept/undone) are tracked separately and not updated here.
+ public var fileEdits: [FileEdit]
+
+ public var turnStatus: TurnStatus?
+
+ public let requestType: RequestType
+
+ // The model name used for the turn.
+ public var modelName: String?
+ public var modelProviderName: String?
+ public var billingMultiplier: Float?
+ public var reasoningEffort: String?
+
/// The timestamp of the message.
public var createdAt: Date
public var updatedAt: Date
@@ -139,8 +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
) {
@@ -156,9 +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
@@ -170,7 +251,8 @@ public struct ChatMessage: Equatable, Codable {
chatTabId: String,
content: String,
contentImageReferences: [ImageReference] = [],
- references: [ConversationReference] = []
+ references: [ConversationReference] = [],
+ requestType: RequestType = .conversation
) {
self.init(
id: id,
@@ -178,7 +260,8 @@ public struct ChatMessage: Equatable, Codable {
role: .user,
content: content,
contentImageReferences: contentImageReferences,
- references: references
+ references: references,
+ requestType: requestType
)
}
@@ -191,7 +274,16 @@ public struct ChatMessage: Equatable, Codable {
suggestedTitle: String? = nil,
steps: [ConversationProgressStep] = [],
editAgentRounds: [AgentRound] = [],
- codeReviewRound: CodeReviewRound? = nil
+ thinking: [MessageThinking] = [],
+ parentTurnId: String? = nil,
+ codeReviewRound: CodeReviewRound? = nil,
+ fileEdits: [FileEdit] = [],
+ turnStatus: TurnStatus? = nil,
+ requestType: RequestType = .conversation,
+ modelName: String? = nil,
+ modelProviderName: String? = nil,
+ billingMultiplier: Float? = nil,
+ reasoningEffort: String? = nil
) {
self.init(
id: id,
@@ -204,7 +296,16 @@ public struct ChatMessage: Equatable, Codable {
suggestedTitle: suggestedTitle,
steps: steps,
editAgentRounds: editAgentRounds,
- codeReviewRound: codeReviewRound
+ thinking: thinking,
+ parentTurnId: parentTurnId,
+ codeReviewRound: codeReviewRound,
+ fileEdits: fileEdits,
+ turnStatus: turnStatus,
+ 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/CodeReview/CodeReviewRound.swift b/Tool/Sources/ConversationServiceProvider/CodeReview/CodeReviewRound.swift
index d9e2c7e9..945733b0 100644
--- a/Tool/Sources/ConversationServiceProvider/CodeReview/CodeReviewRound.swift
+++ b/Tool/Sources/ConversationServiceProvider/CodeReview/CodeReviewRound.swift
@@ -1,6 +1,14 @@
import Foundation
import LanguageServerProtocol
import GitHelper
+import CopilotForXcodeKit
+
+extension WorkspaceInfo: @retroactive Equatable {
+ public static func ==(lhs: WorkspaceInfo, rhs: WorkspaceInfo) -> Bool {
+ return lhs.projectURL == rhs.projectURL
+ && lhs.workspaceURL == rhs.workspaceURL
+ }
+}
public struct CodeReviewRequest: Equatable, Codable {
public struct FileChange: Equatable, Codable {
@@ -14,6 +22,7 @@ public struct CodeReviewRequest: Equatable, Codable {
}
public var fileChange: FileChange
+ public var workspaceInfo: WorkspaceInfo?
public var changedFileUris: [DocumentUri] { fileChange.changes.map { $0.uri } }
public var selectedFileUris: [DocumentUri] { fileChange.selectedChanges.map { $0.uri } }
diff --git a/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift b/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift
index f97260c4..aaa13917 100644
--- a/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift
+++ b/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift
@@ -4,31 +4,40 @@ import CodableWrappers
import LanguageServerProtocol
public protocol ConversationServiceType {
- func createConversation(_ request: ConversationRequest, workspace: WorkspaceInfo) async throws
- func createTurn(with conversationId: String, request: ConversationRequest, workspace: WorkspaceInfo) async throws
+ func createConversation(_ request: ConversationRequest, workspace: WorkspaceInfo) async throws -> ConversationCreateResponse?
+ func createTurn(with conversationId: String, request: ConversationRequest, workspace: WorkspaceInfo) async throws -> ConversationCreateResponse?
+ func deleteTurn(with conversationId: String, turnId: String, workspace: WorkspaceInfo) async throws
func cancelProgress(_ workDoneToken: String, workspace: WorkspaceInfo) async throws
func rateConversation(turnId: String, rating: ConversationRating, workspace: WorkspaceInfo) async throws
func copyCode(request: CopyCodeRequest, workspace: WorkspaceInfo) async throws
func templates(workspace: WorkspaceInfo) async throws -> [ChatTemplate]?
+ func modes(workspace: WorkspaceInfo) async throws -> [ConversationMode]?
func models(workspace: WorkspaceInfo) async throws -> [CopilotModel]?
func notifyDidChangeWatchedFiles(_ event: DidChangeWatchedFilesEvent, workspace: WorkspaceInfo) async throws
func agents(workspace: WorkspaceInfo) async throws -> [ChatAgent]?
func notifyChangeTextDocument(fileURL: URL, content: String, version: Int, workspace: WorkspaceInfo) async throws
- func reviewChanges(workspace: WorkspaceInfo, params: ReviewChangesParams) async throws -> CodeReviewResult?
+ func reviewChanges(
+ workspace: WorkspaceInfo,
+ changes: [ReviewChangesParams.Change]
+ ) async throws -> CodeReviewResult?
+ 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(_ params: ReviewChangesParams) async throws -> CodeReviewResult?
+ func reviewChanges(_ changes: [ReviewChangesParams.Change]) async throws -> CodeReviewResult?
+ func generateThinkingTitle(_ params: GenerateThinkingTitleParams) async throws -> GenerateThinkingTitleResponse?
}
public struct ConversationFileReference: Hashable, Codable, Equatable {
@@ -337,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
@@ -353,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
) {
@@ -368,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
}
@@ -441,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 a0ea9920..88b86933 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,9 @@ public struct CopilotModel: Codable, Equatable {
public let isChatFallback: Bool
public let capabilities: CopilotModelCapabilities
public let billing: CopilotModelBilling?
+ public let degradationReason: String?
+ public let modelPickerCategory: String?
+ public let modelPickerPriceCategory: String?
}
public struct CopilotModelPolicy: Codable, Equatable {
@@ -54,19 +60,106 @@ public struct CopilotModelPolicy: Codable, Equatable {
public struct CopilotModelCapabilities: Codable, Equatable {
public let supports: CopilotModelCapabilitiesSupports
+ public let limits: CopilotModelCapabilitiesLimits?
+}
+
+public struct CopilotModelCapabilitiesLimits: Codable, Equatable {
+ public let maxContextWindowTokens: Int?
+ public let maxOutputTokens: Int?
+ public let maxInputTokens: Int?
+ public let maxNonStreamingOutputTokens: Int?
}
public struct CopilotModelCapabilitiesSupports: Codable, Equatable {
public let vision: Bool
+ public let reasoningEfforts: [String]?
+ public let supportsReasoningEffortLevel: Bool?
}
public struct CopilotModelBilling: Codable, Equatable, Hashable {
public let isPremium: Bool
public let multiplier: Float
-
- public init(isPremium: Bool, multiplier: Float) {
+ public let tokenBasedBillingEnabled: Bool?
+ public let tokenPrices: CopilotModelBillingTokenPrices?
+
+ public init(isPremium: Bool, multiplier: Float, tokenBasedBillingEnabled: Bool? = nil, tokenPrices: CopilotModelBillingTokenPrices? = nil) {
self.isPremium = isPremium
self.multiplier = multiplier
+ self.tokenBasedBillingEnabled = tokenBasedBillingEnabled
+ self.tokenPrices = tokenPrices
+ }
+}
+
+public struct CopilotModelBillingTokenPrices: Codable, Equatable, Hashable {
+ public let cachePrice: Float?
+ public let inputPrice: Float?
+ public let outputPrice: Float?
+ public let tokenUnit: Int?
+}
+
+// MARK: ChatModes
+public enum ChatMode: String, Codable {
+ case Ask = "Ask"
+ case Edit = "Edit"
+ case Agent = "Agent"
+ case InlineAgent = "InlineAgent"
+}
+
+public struct ConversationMode: Codable, Equatable {
+ 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
}
}
@@ -95,6 +188,40 @@ 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(
+ chatmodeKind: ChatMode? = nil,
+ customChatModeId: String? = nil,
+ workspaceFolders: [WorkspaceFolder]? = nil,
+ tools: [ToolStatusUpdate]
+ ) {
+ self.chatModeKind = chatmodeKind
+ self.customChatModeId = customChatModeId
+ self.workspaceFolders = workspaceFolders
+ self.tools = tools
+ }
+}
+
+public struct ToolStatusUpdate: Codable, Equatable {
+ public let name: String
+ public let status: ToolStatus
+
+ public init(name: String, status: ToolStatus) {
+ self.name = name
+ self.status = status
+ }
+}
+
+public enum ToolStatus: String, Codable, Equatable, Hashable {
+ case enabled = "enabled"
+ case disabled = "disabled"
+}
+
public struct LanguageModelToolInformation: Codable, Equatable {
/// The name of the tool.
public let name: String
@@ -158,6 +285,68 @@ public struct LanguageModelToolConfirmationMessages: Codable, Equatable {
}
}
+public struct LanguageModelTool: Codable, Equatable {
+ public let id: String
+ public let type: ToolType
+ public let toolProvider: ToolProvider
+ public let nameForModel: String
+ public let name: String
+ public let displayName: String?
+ public let description: String?
+ public let displayDescription: String
+ public let inputSchema: [String: AnyCodable]?
+ public let annotations: ToolAnnotations?
+ public let status: ToolStatus
+
+ public init(
+ id: String,
+ type: ToolType,
+ toolProvider: ToolProvider,
+ nameForModel: String,
+ name: String,
+ displayName: String?,
+ description: String?,
+ displayDescription: String,
+ inputSchema: [String : AnyCodable]?,
+ annotations: ToolAnnotations?,
+ status: ToolStatus
+ ) {
+ self.id = id
+ self.type = type
+ self.toolProvider = toolProvider
+ self.nameForModel = nameForModel
+ self.name = name
+ self.displayName = displayName
+ self.description = description
+ self.displayDescription = displayDescription
+ self.inputSchema = inputSchema
+ self.annotations = annotations
+ self.status = status
+ }
+}
+
+public enum ToolType: String, Codable, CaseIterable {
+ case shared = "shared"
+ case client = "client"
+ case mcp = "mcp"
+}
+
+public struct ToolProvider: Codable, Equatable {
+ public let id: String
+ public let displayName: String
+ public let displayNamePrefix: String?
+ public let description: String
+ public let isFirstPartyTool: Bool
+}
+
+public struct ToolAnnotations: Codable, Equatable {
+ public let title: String?
+ public let readOnlyHint: Bool?
+ public let destructiveHint: Bool?
+ public let idempotentHint: Bool?
+ public let openWorldHint: Bool?
+}
+
public struct InvokeClientToolParams: Codable, Equatable {
/// The name of the tool to be invoked.
public let name: String
@@ -350,6 +539,20 @@ public struct ActionCommand: Codable, Equatable, Hashable {
// MARK: - Copilot Code Review
+public struct GenerateThinkingTitleParams: Codable {
+ public var thinkingContent: String?
+ public var extractedTitles: [String]?
+
+ public init(thinkingContent: String? = nil, extractedTitles: [String]? = nil) {
+ self.thinkingContent = thinkingContent
+ self.extractedTitles = extractedTitles
+ }
+}
+
+public struct GenerateThinkingTitleResponse: Codable {
+ public var title: String
+}
+
public struct ReviewChangesParams: Codable, Equatable {
public struct Change: Codable, Equatable {
public let uri: DocumentUri
@@ -368,15 +571,17 @@ public struct ReviewChangesParams: Codable, Equatable {
}
public let changes: [Change]
+ public let workspaceFolders: [WorkspaceFolder]?
- public init(changes: [Change]) {
+ public init(changes: [Change], workspaceFolders: [WorkspaceFolder]? = nil) {
self.changes = changes
+ self.workspaceFolders = workspaceFolders
}
}
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
@@ -385,7 +590,7 @@ public struct ReviewComment: Codable, Equatable, Hashable {
// enum: low, medium, high
public let severity: String
public let suggestion: String?
-
+
public init(
uri: DocumentUri,
range: LSPRange,
@@ -394,6 +599,7 @@ public struct ReviewComment: Codable, Equatable, Hashable {
severity: String,
suggestion: String?
) {
+ self.id = UUID().uuidString
self.uri = uri
self.range = range
self.message = message
@@ -401,6 +607,21 @@ public struct ReviewComment: Codable, Equatable, Hashable {
self.severity = severity
self.suggestion = suggestion
}
+
+ private enum CodingKeys: String, CodingKey {
+ case id, uri, range, message, kind, severity, suggestion
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ self.id = try container.decodeIfPresent(String.self, forKey: .id) ?? UUID().uuidString
+ self.uri = try container.decode(DocumentUri.self, forKey: .uri)
+ self.range = try container.decode(LSPRange.self, forKey: .range)
+ self.message = try container.decode(String.self, forKey: .message)
+ self.kind = try container.decode(String.self, forKey: .kind)
+ self.severity = try container.decode(String.self, forKey: .severity)
+ self.suggestion = try container.decodeIfPresent(String.self, forKey: .suggestion)
+ }
}
public struct CodeReviewResult: Codable, Equatable {
@@ -410,3 +631,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 7b9d12c9..8ee3e077 100644
--- a/Tool/Sources/ConversationServiceProvider/ToolNames.swift
+++ b/Tool/Sources/ConversationServiceProvider/ToolNames.swift
@@ -1,5 +1,5 @@
-public enum ToolName: String {
+public enum ToolName: String, Codable {
case runInTerminal = "run_in_terminal"
case getTerminalOutput = "get_terminal_output"
case getErrors = "get_errors"
@@ -7,3 +7,16 @@ public enum ToolName: String {
case createFile = "create_file"
case fetchWebPage = "fetch_webpage"
}
+
+public enum ServerToolName: String, Codable {
+ case readFile = "read_file"
+ case findFiles = "file_search"
+ case findTextInFiles = "grep_search"
+ case listDir = "list_dir"
+ case replaceString = "replace_string_in_file"
+ case codebase = "semantic_search"
+}
+
+public enum CopilotToolName: String, Codable {
+ case readFile = "copilot.read_file"
+}
diff --git a/Tool/Sources/GitHubCopilotService/Conversation/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/ClientToolRegistry.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/ClientToolRegistry.swift
index e78c9cde..3d7d5cfd 100644
--- a/Tool/Sources/GitHubCopilotService/LanguageServer/ClientToolRegistry.swift
+++ b/Tool/Sources/GitHubCopilotService/LanguageServer/ClientToolRegistry.swift
@@ -1,7 +1,7 @@
import ConversationServiceProvider
-func registerClientTools(server: GitHubCopilotConversationServiceType) async {
+func registerClientTools(server: GitHubCopilotConversationServiceType) async -> [LanguageModelTool] {
var tools: [LanguageModelToolInformation] = []
let runInTerminalTool = LanguageModelToolInformation(
name: ToolName.runInTerminal.rawValue,
@@ -122,6 +122,9 @@ func registerClientTools(server: GitHubCopilotConversationServiceType) async {
tools.append(fetchWebPageTool)
if !tools.isEmpty {
- try? await server.registerTools(tools: tools)
+ let response = try? await server.registerTools(tools: tools)
+ return response ?? []
}
+
+ return []
}
diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLanguageModelToolManager.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLanguageModelToolManager.swift
new file mode 100644
index 00000000..2c530455
--- /dev/null
+++ b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLanguageModelToolManager.swift
@@ -0,0 +1,108 @@
+import ConversationServiceProvider
+import Foundation
+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 {
+ private static var availableLanguageModelTools: [LanguageModelTool]?
+
+ public static func updateToolsStatus(_ tools: [LanguageModelTool]) {
+ // If we have no previous snapshot, just adopt what we received.
+ guard let previous = availableLanguageModelTools, !previous.isEmpty else {
+ let sorted = sortTools(tools)
+ guard sorted != availableLanguageModelTools else { return }
+ availableLanguageModelTools = sorted
+ DispatchQueue.main.async {
+ Logger.client.info("Notify about language model tools change: \(getLanguageModelToolsSummary())")
+ DistributedNotificationCenter.default().post(name: .gitHubCopilotToolsDidChange, object: nil)
+ }
+ return
+ }
+
+ // Map previous and new by name for merging.
+ 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] = []
+
+ for (name, oldTool) in previousByName {
+ if let updated = incomingByName[name] {
+ merged.append(updated)
+ } else {
+ if oldTool.status == .disabled {
+ merged.append(oldTool) // already disabled, keep as-is
+ } else {
+ // Synthesize a disabled copy (all fields same except status).
+ let disabledCopy = LanguageModelTool(
+ id: oldTool.id,
+ type: oldTool.type,
+ toolProvider: oldTool.toolProvider,
+ nameForModel: oldTool.nameForModel,
+ name: oldTool.name,
+ displayName: oldTool.displayName,
+ description: oldTool.description,
+ displayDescription: oldTool.displayDescription,
+ inputSchema: oldTool.inputSchema,
+ annotations: oldTool.annotations,
+ status: .disabled
+ )
+ merged.append(disabledCopy)
+ }
+ }
+ }
+
+ for (name, newTool) in incomingByName {
+ if previousByName[name] == nil {
+ merged.append(newTool)
+ }
+ }
+
+ let sorted = sortTools(merged)
+ guard sorted != availableLanguageModelTools else { return }
+ availableLanguageModelTools = sorted
+
+ DispatchQueue.main.async {
+ Logger.client.info("Notify about language model tools change (merged): \(getLanguageModelToolsSummary())")
+ DistributedNotificationCenter.default().post(name: .gitHubCopilotToolsDidChange, object: nil)
+ }
+ }
+
+ // Extracted sorting logic to keep behavior identical.
+ private static func sortTools(_ tools: [LanguageModelTool]) -> [LanguageModelTool] {
+ tools.sorted { lhs, rhs in
+ let lKey = lhs.displayName ?? lhs.name
+ let rKey = rhs.displayName ?? rhs.name
+ let primary = lKey.localizedCaseInsensitiveCompare(rKey)
+ if primary == .orderedSame {
+ return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
+ }
+ return primary == .orderedAscending
+ }
+ }
+
+ private static func getLanguageModelToolsSummary() -> String {
+ guard let tools = availableLanguageModelTools else { return "" }
+ return "\(tools.filter { $0.status == .enabled }.count) enabled, \(tools.filter { $0.status == .disabled }.count) disabled."
+ }
+
+ public static func getAvailableLanguageModelTools() -> [LanguageModelTool]? {
+ return availableLanguageModelTools
+ }
+
+ public static func hasLanguageModelTools() -> Bool {
+ return availableLanguageModelTools != nil && !availableLanguageModelTools!.isEmpty
+ }
+
+ public static func clearLanguageModelTools() {
+ availableLanguageModelTools = []
+ DispatchQueue.main.async {
+ DistributedNotificationCenter.default().post(name: .gitHubCopilotToolsDidChange, object: nil)
+ }
+ }
+}
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 e82c13ac..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
@@ -353,6 +457,18 @@ enum GitHubCopilotRequest {
}
}
+ struct DeleteTurn: GitHubCopilotRequestType {
+ struct Response: Codable {}
+
+ var params: TurnDeleteParams
+
+ var request: ClientRequest {
+ let data = (try? JSONEncoder().encode(params)) ?? Data()
+ let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:])
+ return .custom("conversation/turnDelete", dict, ClientRequest.NullHandler)
+ }
+ }
+
// MARK: Conversation rating
struct ConversationRating: GitHubCopilotRequestType {
@@ -366,12 +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 {
@@ -381,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
@@ -388,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 {
@@ -402,9 +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
@@ -412,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([:])
@@ -428,7 +594,7 @@ enum GitHubCopilotRequest {
}
struct RegisterTools: GitHubCopilotRequestType {
- struct Response: Codable {}
+ typealias Response = Array
var params: RegisterToolsParams
@@ -439,6 +605,18 @@ enum GitHubCopilotRequest {
}
}
+ struct UpdateToolsStatus: GitHubCopilotRequestType {
+ typealias Response = Array
+
+ var params: UpdateToolsStatusParams
+
+ var request: ClientRequest {
+ let data = (try? JSONEncoder().encode(params)) ?? Data()
+ let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:])
+ return .custom("conversation/updateToolsStatus", dict, ClientRequest.NullHandler)
+ }
+ }
+
// MARK: Copy code
struct CopyCode: GitHubCopilotRequestType {
@@ -452,7 +630,7 @@ enum GitHubCopilotRequest {
return .custom("conversation/copyCode", dict, ClientRequest.NullHandler)
}
}
-
+
// MARK: Telemetry
struct TelemetryException: GitHubCopilotRequestType {
@@ -466,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 {
@@ -479,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 {
@@ -491,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 {
@@ -503,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 {
@@ -515,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 {
@@ -527,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 {
@@ -539,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"
@@ -555,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
}
}
}
@@ -575,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"
@@ -588,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/GitHubCopilotRequest+BYOK.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/BYOK.swift
similarity index 100%
rename from Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+BYOK.swift
rename to Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/BYOK.swift
diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Conversation.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/Conversation.swift
similarity index 57%
rename from Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Conversation.swift
rename to Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/Conversation.swift
index 355e04ee..b2906139 100644
--- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Conversation.swift
+++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/Conversation.swift
@@ -6,105 +6,6 @@ import ConversationServiceProvider
import JSONRPC
import Logger
-enum ConversationSource: String, Codable {
- case panel, inline
-}
-
-public struct FileReference: Codable, Equatable, Hashable {
- public var type: String = "file"
- public let uri: String
- public let position: Position?
- public let visibleRange: SuggestionBasic.CursorRange?
- public let selection: SuggestionBasic.CursorRange?
- public let openedAt: String?
- public let activeAt: String?
-}
-
-public struct DirectoryReference: Codable, Equatable, Hashable {
- public var type: String = "directory"
- public let uri: String
-}
-
-public enum Reference: Codable, Equatable, Hashable {
- case file(FileReference)
- case directory(DirectoryReference)
-
- public func encode(to encoder: Encoder) throws {
- switch self {
- case .file(let fileRef):
- try fileRef.encode(to: encoder)
- case .directory(let directoryRef):
- try directoryRef.encode(to: encoder)
- }
- }
-
- private enum CodingKeys: String, CodingKey {
- case type
- }
-
- public init(from decoder: Decoder) throws {
- let container = try decoder.container(keyedBy: CodingKeys.self)
- let type = try container.decode(String.self, forKey: .type)
-
- switch type {
- case "file":
- let fileRef = try FileReference(from: decoder)
- self = .file(fileRef)
- case "directory":
- let directoryRef = try DirectoryReference(from: decoder)
- self = .directory(directoryRef)
- default:
- throw DecodingError.dataCorrupted(
- DecodingError.Context(
- codingPath: decoder.codingPath,
- debugDescription: "Unknown reference type: \(type)"
- )
- )
- }
- }
-
- public static func from(_ ref: ConversationAttachedReference) -> Reference {
- switch ref {
- case .file(let fileRef):
- return .file(
- .init(
- uri: fileRef.url.absoluteString,
- position: nil,
- visibleRange: nil,
- selection: nil,
- openedAt: nil,
- activeAt: nil
- )
- )
- case .directory(let directoryRef):
- return .directory(.init(uri: directoryRef.url.absoluteString))
- }
- }
-}
-
-struct ConversationCreateParams: Codable {
- var workDoneToken: String
- var turns: [TurnSchema]
- var capabilities: Capabilities
- var textDocument: Doc?
- var references: [Reference]?
- var computeSuggestions: Bool?
- var source: ConversationSource?
- var workspaceFolder: String?
- var workspaceFolders: [WorkspaceFolder]?
- var ignoredSkills: [String]?
- var model: String?
- var modelProviderName: String?
- var chatMode: String?
- var needToolCallConfirmation: Bool?
- var userLanguage: String?
-
- struct Capabilities: Codable {
- var skills: [String]
- var allSkills: Bool?
- }
-}
-
// MARK: Conversation Progress
public enum ConversationProgressKind: String, Codable {
@@ -121,6 +22,7 @@ public struct ConversationProgressBegin: BaseConversationProgress {
public let kind: ConversationProgressKind
public let conversationId: String
public let turnId: String
+ public let parentTurnId: String?
}
public struct ConversationProgressReport: BaseConversationProgress {
@@ -132,6 +34,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,12 +107,20 @@ 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?
}
+struct TurnDeleteParams: Codable {
+ var conversationId: String
+ var turnId: String
+ var source: ConversationSource?
+}
+
// MARK: Copy
struct CopyCodeParams: Codable {
@@ -236,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/GitHubCopilotRequest+MCP.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCP.swift
similarity index 65%
rename from Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+MCP.swift
rename to Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCP.swift
index 1ad669a1..17792a88 100644
--- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+MCP.swift
+++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCP.swift
@@ -1,16 +1,13 @@
import Foundation
import JSONRPC
import LanguageServerProtocol
+import ConversationServiceProvider
public enum MCPServerStatus: String, Codable, Equatable, Hashable {
case running = "running"
case stopped = "stopped"
case error = "error"
-}
-
-public enum MCPToolStatus: String, Codable, Equatable, Hashable {
- case enabled = "enabled"
- case disabled = "disabled"
+ case blocked = "blocked"
}
public struct InputSchema: Codable, Equatable, Hashable {
@@ -81,14 +78,14 @@ public struct ToolAnnotations: Codable, Equatable, Hashable {
public struct MCPTool: Codable, Equatable, Hashable {
public let name: String
public let description: String?
- public let _status: MCPToolStatus
+ public let _status: ToolStatus
public let inputSchema: InputSchema
public var annotations: ToolAnnotations?
public init(
name: String,
description: String? = nil,
- _status: MCPToolStatus,
+ _status: ToolStatus,
inputSchema: InputSchema,
annotations: ToolAnnotations? = nil
) {
@@ -113,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
}
}
@@ -132,9 +137,9 @@ public struct GetAllToolsParams: Codable, Hashable {
public struct UpdatedMCPToolsStatus: Codable, Hashable {
public var name: String
- public var status: MCPToolStatus
+ public var status: ToolStatus
- public init(name: String, status: MCPToolStatus) {
+ public init(name: String, status: ToolStatus) {
self.name = name
self.status = status
}
@@ -151,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
new file mode 100644
index 00000000..fd1c8bf6
--- /dev/null
+++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCPRegistry.swift
@@ -0,0 +1,768 @@
+import Foundation
+import JSONRPC
+import ConversationServiceProvider
+
+/// Schema definitions for MCP Registry API based on the OpenAPI spec:
+/// https://github.com/modelcontextprotocol/registry/blob/v1.3.3/docs/reference/api/openapi.yaml
+
+// MARK: - Inputs
+
+public enum ArgumentFormat: String, Codable {
+ case string
+ case number
+ case boolean
+ case filepath
+}
+
+public protocol InputProtocol: Codable {
+ var description: String? { get }
+ var isRequired: Bool? { get }
+ var format: ArgumentFormat? { get }
+ var value: String? { get }
+ var isSecret: Bool? { get }
+ var defaultValue: String? { get }
+ var placeholder: String? { get }
+ var choices: [String]? { get }
+}
+
+public struct Input: InputProtocol {
+ 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]?
+
+ enum CodingKeys: String, CodingKey {
+ case description, isRequired, format, value, isSecret, placeholder, choices
+ case defaultValue = "default"
+ }
+}
+
+public struct InputWithVariables: InputProtocol {
+ 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]?
+
+ enum CodingKeys: String, CodingKey {
+ case description, isRequired, format, value, isSecret, placeholder, choices, variables
+ case defaultValue = "default"
+ }
+}
+
+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
+ }
+
+ 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: - Arguments
+
+public enum ArgumentType: String, Codable {
+ case positional
+ case named
+}
+
+public protocol ArgumentProtocol: InputProtocol {
+ var type: ArgumentType { get }
+ var variables: [String: Input]? { get }
+}
+
+public struct PositionalArgument: ArgumentProtocol, Hashable {
+ public let type: ArgumentType = .positional
+ public let description: String?
+ public let isRequired: Bool?
+ public let format: ArgumentFormat?
+ public let value: String?
+ public let isSecret: Bool?
+ public let defaultValue: String?
+ public let placeholder: String?
+ public let choices: [String]?
+ public let variables: [String: Input]?
+ public let valueHint: String?
+ public let isRepeated: Bool?
+
+ enum CodingKeys: String, CodingKey {
+ case type, description, isRequired, format, value, isSecret, placeholder, choices, variables, valueHint, isRepeated
+ case defaultValue = "default"
+ }
+
+ 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
+ }
+}
+
+public struct NamedArgument: ArgumentProtocol, Hashable {
+ public let type: ArgumentType = .named
+ public let name: String
+ public let description: String?
+ public let isRequired: Bool?
+ public let format: ArgumentFormat?
+ public let value: String?
+ public let isSecret: Bool?
+ public let defaultValue: String?
+ public let placeholder: String?
+ public let choices: [String]?
+ public let variables: [String: Input]?
+ public let isRepeated: Bool?
+
+ enum CodingKeys: String, CodingKey {
+ case type, name, description, isRequired, format, value, isSecret, placeholder, choices, variables, isRepeated
+ case defaultValue = "default"
+ }
+
+ 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, Hashable {
+ case positional(PositionalArgument)
+ case named(NamedArgument)
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: Discriminator.self)
+ let type = try container.decode(ArgumentType.self, forKey: .type)
+ switch type {
+ case .positional:
+ self = .positional(try PositionalArgument(from: decoder))
+ case .named:
+ self = .named(try NamedArgument(from: decoder))
+ }
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ switch self {
+ case .positional(let arg):
+ try arg.encode(to: encoder)
+ case .named(let arg):
+ try arg.encode(to: encoder)
+ }
+ }
+
+ private enum Discriminator: String, CodingKey {
+ case type
+ }
+}
+
+// MARK: - Transport
+
+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 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, Hashable {
+ public let registryType: String
+ public let registryBaseUrl: String?
+ public let identifier: String
+ public let version: String?
+ public let fileSha256: String?
+ public let runtimeHint: String?
+ public let transport: Transport
+ public let runtimeArguments: [Argument]?
+ public let packageArguments: [Argument]?
+ public let environmentVariables: [KeyValueInput]?
+
+ public init(
+ registryType: String,
+ registryBaseUrl: String?,
+ identifier: String,
+ 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: - Icons
+
+public enum IconMimeType: String, Codable {
+ case png = "image/png"
+ case jpeg = "image/jpeg"
+ case jpg = "image/jpg"
+ case svg = "image/svg+xml"
+ case webp = "image/webp"
+}
+
+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: - Repository
+
+public struct Repository: Codable {
+ public let url: String
+ public let source: String
+ public let id: String?
+ public let subfolder: String?
+
+ public init(url: String, source: String, id: String?, subfolder: String?) {
+ self.url = url
+ self.source = source
+ self.id = id
+ self.subfolder = subfolder
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case url, source, id, subfolder
+ }
+}
+
+// MARK: - Meta
+
+public enum ServerStatus: String, Codable {
+ case active
+ case deprecated
+ case deleted
+}
+
+public struct OfficialMeta: Codable {
+ public let status: ServerStatus?
+ public let publishedAt: String?
+ public let updatedAt: String?
+ public let isLatest: Bool?
+
+ public init(
+ status: ServerStatus? = nil,
+ publishedAt: String? = nil,
+ updatedAt: String? = nil,
+ isLatest: Bool? = nil
+ ) {
+ self.status = status
+ self.publishedAt = publishedAt
+ self.updatedAt = updatedAt
+ self.isLatest = isLatest
+ }
+}
+
+public struct PublisherProvidedMeta: Codable {
+ private let additionalProperties: [String: AnyCodable]?
+
+ public init(
+ additionalProperties: [String: AnyCodable]? = nil
+ ) {
+ self.additionalProperties = additionalProperties
+ }
+
+ public init(from decoder: Decoder) throws {
+ let allKeys = try decoder.container(keyedBy: AnyCodingKey.self)
+ var extras: [String: AnyCodable] = [:]
+
+ for key in allKeys.allKeys {
+ extras[key.stringValue] = try allKeys.decode(AnyCodable.self, forKey: key)
+ }
+ additionalProperties = extras.isEmpty ? nil : extras
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ if let additionalProperties = additionalProperties {
+ var dynamicContainer = encoder.container(keyedBy: AnyCodingKey.self)
+ for (key, value) in additionalProperties {
+ try dynamicContainer.encode(value, forKey: AnyCodingKey(stringValue: key)!)
+ }
+ }
+ }
+}
+
+public struct MCPRegistryExtensionMeta: Codable {
+ public let publisherProvided: PublisherProvidedMeta?
+
+ enum CodingKeys: String, CodingKey {
+ case publisherProvided = "io.modelcontextprotocol.registry/publisher-provided"
+ }
+
+ public init(publisherProvided: PublisherProvidedMeta?) {
+ self.publisherProvided = publisherProvided
+ }
+}
+
+public struct ServerMeta: Codable {
+ public let official: OfficialMeta?
+ private let additionalProperties: [String: AnyCodable]?
+
+ enum CodingKeys: String, CodingKey {
+ 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)
+ official = try container.decode(OfficialMeta.self, forKey: .official)
+
+ let allKeys = try decoder.container(keyedBy: AnyCodingKey.self)
+ var extras: [String: AnyCodable] = [:]
+
+ for key in allKeys.allKeys {
+ if key.stringValue != "io.modelcontextprotocol.registry/official" {
+ 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(official, forKey: .official)
+
+ if let additionalProperties = additionalProperties {
+ var dynamicContainer = encoder.container(keyedBy: AnyCodingKey.self)
+ for (key, value) in additionalProperties {
+ try dynamicContainer.encode(value, forKey: AnyCodingKey(stringValue: key)!)
+ }
+ }
+ }
+}
+
+// MARK: - Servers
+
+public struct MCPRegistryServerDetail: Codable {
+ public let name: String
+ public let description: String
+ public let title: String?
+ public let repository: Repository?
+ public let version: String
+ public let websiteUrl: String?
+ public let icons: [Icon]?
+ public let schemaURL: String?
+ public let packages: [Package]?
+ public let remotes: [Remote]?
+ public let meta: MCPRegistryExtensionMeta?
+
+ enum CodingKeys: String, CodingKey {
+ case name, description, title, repository, version, packages, remotes, websiteUrl, icons
+ case schemaURL = "$schema"
+ case meta = "_meta"
+ }
+
+ public init(
+ name: String,
+ description: String,
+ 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
+ }
+ }
+}
+
+public struct MCPRegistryServerResponse : Codable {
+ public let server: MCPRegistryServerDetail
+ public let meta: ServerMeta?
+
+ public init(server: MCPRegistryServerDetail, meta: ServerMeta? = nil) {
+ self.server = server
+ self.meta = meta
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case server
+ case meta = "_meta"
+ }
+}
+
+public struct MCPRegistryServerListMetadata: Codable {
+ public let nextCursor: String?
+ public let count: Int?
+}
+
+public struct MCPRegistryServerList: Codable {
+ public let servers: [MCPRegistryServerResponse]
+ public let metadata: MCPRegistryServerListMetadata?
+}
+
+// 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?,
+ 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
+ }
+}
+
+public struct MCPRegistryGetServerParams: Codable {
+ public let baseUrl: String
+ public let id: String
+ public let version: String?
+
+ public init(baseUrl: String, id: String, version: String?) {
+ self.baseUrl = baseUrl
+ self.id = id
+ 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/GithubCopilotRequest+Message.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/Message.swift
similarity index 100%
rename from Tool/Sources/GitHubCopilotService/LanguageServer/GithubCopilotRequest+Message.swift
rename to Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/Message.swift
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/GitHubCopilotRequest+Telemetry.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/Telemetry.swift
similarity index 100%
rename from Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Telemetry.swift
rename to Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/Telemetry.swift
diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift
index fae337cd..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,15 +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
+ func registerTools(tools: [LanguageModelToolInformation]) async throws -> [LanguageModelTool]
+ func updateToolsStatus(params: UpdateToolsStatusParams) async throws -> [LanguageModelTool]
+ func generateThinkingTitle(params: GenerateThinkingTitleParams) async throws -> GenerateThinkingTitleResponse
}
protocol GitHubCopilotLSP {
@@ -147,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 {
@@ -192,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
@@ -252,6 +281,7 @@ public class GitHubCopilotBaseService {
experimental: nil
)
+ let authAppId = Bundle.main.infoDictionary?["GITHUB_APP_ID"] as? String
return InitializeParams(
processId: Int(ProcessInfo.processInfo.processIdentifier),
locale: nil,
@@ -269,8 +299,12 @@ 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,
],
capabilities: capabilities,
trace: .off,
@@ -381,6 +415,30 @@ func getTerminalEnvironmentVariables(_ variableNames: [String]) -> [String: Stri
public static let shared = TheActor()
}
+actor ToolInitializationActor {
+ private var isInitialized = false
+ private var unrestoredTools: [ToolStatusUpdate] = []
+
+ func loadUnrestoredToolsIfNeeded() -> [ToolStatusUpdate] {
+ guard !isInitialized else { return unrestoredTools }
+ isInitialized = true
+
+ // Load tools only once
+ if let savedJSON = AppState.shared.get(key: "languageModelToolsStatus"),
+ let data = try? JSONEncoder().encode(savedJSON),
+ let savedTools = try? JSONDecoder().decode([ToolStatusUpdate].self, from: data) {
+ let currentlyAvailableTools = CopilotLanguageModelToolManager.getAvailableLanguageModelTools() ?? []
+ let availableToolNames = Set(currentlyAvailableTools.map { $0.name })
+
+ unrestoredTools = savedTools.filter {
+ availableToolNames.contains($0.name) && $0.status == .disabled
+ }
+ }
+
+ return unrestoredTools
+ }
+}
+
public final class GitHubCopilotService:
GitHubCopilotBaseService,
GitHubCopilotSuggestionServiceType,
@@ -394,10 +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)
@@ -409,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 != "/" {
@@ -435,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
}
@@ -455,7 +514,9 @@ public final class GitHubCopilotService:
GitHubCopilotService.services.append(self)
Task {
- await registerClientTools(server: self)
+ let tools = await registerClientTools(server: self)
+ CopilotLanguageModelToolManager.updateToolsStatus(tools)
+ await restoreRegisteredToolsStatus()
}
} catch {
Logger.gitHubCopilot.error(error)
@@ -486,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,
@@ -533,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
}
}
@@ -571,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 {
@@ -614,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)")
@@ -627,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,
@@ -652,14 +740,28 @@ 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)")
throw error
}
}
+
+ @GitHubCopilotSuggestionActor
+ public func deleteTurn(conversationId: String, turnId: String) async throws {
+ do {
+ let params = TurnDeleteParams(conversationId: conversationId, turnId: turnId, source: .panel)
+ _ = try await sendRequest(GitHubCopilotRequest.DeleteTurn(params: params))
+ } catch {
+ throw error
+ }
+ }
@GitHubCopilotSuggestionActor
public func templates(workspaceFolders: [WorkspaceFolder]? = nil) async throws -> [ChatTemplate] {
@@ -673,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] {
@@ -711,11 +826,29 @@ public final class GitHubCopilotService:
}
@GitHubCopilotSuggestionActor
- public func registerTools(tools: [LanguageModelToolInformation]) async throws {
+ 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 {
- _ = try await sendRequest(
+ let response = try await sendRequest(
GitHubCopilotRequest.RegisterTools(params: RegisterToolsParams(tools: tools))
)
+ return response
+ } catch {
+ throw error
+ }
+ }
+
+ @GitHubCopilotSuggestionActor
+ public func updateToolsStatus(params: UpdateToolsStatusParams) async throws -> [LanguageModelTool] {
+ do {
+ let response = try await sendRequest(
+ GitHubCopilotRequest.UpdateToolsStatus(params: params)
+ )
+ return response
} catch {
throw error
}
@@ -733,6 +866,41 @@ public final class GitHubCopilotService:
}
}
+ @GitHubCopilotSuggestionActor
+ public func listMCPRegistryServers(_ params: MCPRegistryListServersParams) async throws -> MCPRegistryServerList {
+ do {
+ let response = try await sendRequest(
+ GitHubCopilotRequest.MCPRegistryListServers(params: params)
+ )
+ return response
+ } catch {
+ throw error
+ }
+ }
+
+ @GitHubCopilotSuggestionActor
+ public func getMCPRegistryServer(_ params: MCPRegistryGetServerParams) async throws -> MCPRegistryServerDetail {
+ do {
+ let response = try await sendRequest(
+ GitHubCopilotRequest.MCPRegistryGetServer(params: params)
+ )
+ return response
+ } catch {
+ 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 {
@@ -777,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 {
@@ -784,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 {
@@ -818,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
)
)
)
@@ -1136,69 +1314,113 @@ public final class GitHubCopilotService:
Logger.gitHubCopilot.error("Failed to update MCP Tools status: \(updateError)")
}
}
+
+ public static func updateAllCLSTools(tools: [ToolStatusUpdate]) async -> [LanguageModelTool] {
+ var updateError: Error? = nil
+ var updatedTools: [LanguageModelTool] = []
- 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 }
+ for service in services {
+ if service.projectRootURL.path == "/" {
+ continue // Skip services with root project URL
+ }
+
+ do {
+ updatedTools = try await service.updateToolsStatus(
+ params: .init(tools: tools)
+ )
+ } catch let error as ServerError {
+ updateError = GitHubCopilotError.languageServerError(error)
+ } catch {
+ updateError = error
+ }
}
+
+ CopilotLanguageModelToolManager.updateToolsStatus(updatedTools)
+ Logger.gitHubCopilot.info("Updated All Built-In Tools: \(tools.count) tools")
- return []
+ if let updateError {
+ Logger.gitHubCopilot.error("Failed to update Built-In Tools status: \(updateError)")
+ }
+
+ return updatedTools
}
-
- 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
+
+ /// 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 {
- let savedServers = savedStatus.filter { mcpServers.contains($0.name) }
- if savedServers.isEmpty {
- return nil
- } else {
- return try await updateMCPToolsStatus(
- params: .init(servers: savedServers)
- )
+ // 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 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)")
+ Logger.gitHubCopilot.error("Failed to refresh client tools: \(error)")
}
-
- return nil
}
- public func handleMCPToolsNotification(_ notification: AnyJSONRPCNotification) async {
- defer {
- self.isMCPInitialized = true
+ private func loadUnrestoredLanguageModelTools() -> [ToolStatusUpdate] {
+ if let savedJSON = AppState.shared.get(key: "languageModelToolsStatus"),
+ let data = try? JSONEncoder().encode(savedJSON),
+ let savedTools = try? JSONDecoder().decode([ToolStatusUpdate].self, from: data) {
+ return savedTools
}
+ return []
+ }
+
+ private func restoreRegisteredToolsStatus() async {
+ // Get unrestored tools from the shared coordinator
+ let toolsToRestore = await GitHubCopilotService.toolInitializationActor.loadUnrestoredToolsIfNeeded()
- if !self.isMCPInitialized {
- self.unrestoredMcpServers = self.loadUnrestoredMCPServers()
+ guard !toolsToRestore.isEmpty else {
+ Logger.gitHubCopilot.info("No previously disabled tools need to be restored")
+ return
}
-
+
+ do {
+ let updatedTools = try await updateToolsStatus(params: .init(tools: toolsToRestore))
+ CopilotLanguageModelToolManager.updateToolsStatus(updatedTools)
+ Logger.gitHubCopilot.info("Restored \(toolsToRestore.count) disabled tools for service at \(projectRootURL.path)")
+ } catch {
+ Logger.gitHubCopilot.error("Failed to restore tools for service at \(projectRootURL.path): \(error)")
+ }
+ }
+
+ public func handleMCPToolsNotification(_ notification: AnyJSONRPCNotification) async {
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)
}
}
@@ -1237,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 != "/" {
@@ -1252,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 a4248e8f..23fd920f 100644
--- a/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift
+++ b/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift
@@ -20,6 +20,7 @@ public struct DidChangeFeatureFlagsParams: Hashable, Codable {
let envelope: [String: JSONValue]
let token: [String: String]
let activeExps: ActiveExperimentForFeatureFlags
+ let byok: Bool?
}
public struct FeatureFlags: Hashable, Codable {
@@ -31,8 +32,11 @@ public struct FeatureFlags: Hashable, Codable {
public var agentMode: Bool
public var mcp: Bool
public var ccr: Bool // Copilot Code Review
+ public var byok: Bool
+ public var editorPreviewFeatures: Bool
public var activeExperimentForFeatureFlags: ActiveExperimentForFeatureFlags
-
+ public var agentModeAutoApproval: Bool
+
public init(
restrictedTelemetry: Bool = true,
snippy: Bool = true,
@@ -42,7 +46,10 @@ public struct FeatureFlags: Hashable, Codable {
agentMode: Bool = true,
mcp: Bool = true,
ccr: Bool = true,
- activeExperimentForFeatureFlags: ActiveExperimentForFeatureFlags = [:]
+ byok: Bool = true,
+ editorPreviewFeatures: Bool = true,
+ activeExperimentForFeatureFlags: ActiveExperimentForFeatureFlags = [:],
+ agentModeAutoApproval: Bool = true
) {
self.restrictedTelemetry = restrictedTelemetry
self.snippy = snippy
@@ -52,7 +59,10 @@ public struct FeatureFlags: Hashable, Codable {
self.agentMode = agentMode
self.mcp = mcp
self.ccr = ccr
+ self.byok = byok
+ self.editorPreviewFeatures = editorPreviewFeatures
self.activeExperimentForFeatureFlags = activeExperimentForFeatureFlags
+ self.agentModeAutoApproval = agentModeAutoApproval
}
}
@@ -69,7 +79,12 @@ public class FeatureFlagNotifierImpl: FeatureFlagNotifier {
public var featureFlagsDidChange: PassthroughSubject
init(
- didChangeFeatureFlagsParams: DidChangeFeatureFlagsParams = .init(envelope: [:], token: [:], activeExps: [:]),
+ didChangeFeatureFlagsParams: DidChangeFeatureFlagsParams = .init(
+ envelope: [:],
+ token: [:],
+ activeExps: [:],
+ byok: nil
+ ),
featureFlags: FeatureFlags = FeatureFlags(),
featureFlagsDidChange: PassthroughSubject = PassthroughSubject()
) {
@@ -79,16 +94,18 @@ 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"
self.featureFlags.ccr = self.didChangeFeatureFlagsParams.token["ccr"] != "0"
+ 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 c795d5f2..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,17 @@ 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 {
+ guard let service = await serviceLocator.getService(from: workspace) else { return }
+
+ return try await service.deleteTurn(conversationId: conversationId, turnId: turnId)
}
public func cancelProgress(_ workDoneToken: String, workspace: WorkspaceInfo) async throws {
@@ -97,7 +111,18 @@ public final class GitHubCopilotConversationService: ConversationServiceType {
public func templates(workspace: WorkspaceInfo) async throws -> [ChatTemplate]? {
guard let service = await serviceLocator.getService(from: workspace) else { return nil }
- return try await service.templates(workspaceFolders: getWorkspaceFolders(workspace: workspace))
+ let isPreviewEnabled = FeatureFlagNotifierImpl.shared.featureFlags.editorPreviewFeatures
+ 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]? {
@@ -118,10 +143,25 @@ public final class GitHubCopilotConversationService: ConversationServiceType {
return try await service.agents()
}
- public func reviewChanges(workspace: WorkspaceInfo, params: ReviewChangesParams) async throws -> CodeReviewResult? {
+ public func reviewChanges(
+ workspace: WorkspaceInfo,
+ changes: [ReviewChangesParams.Change]
+ ) async throws -> CodeReviewResult? {
guard let service = await serviceLocator.getService(from: workspace) else { return nil }
- return try await service.reviewChanges(params: params)
+ return try await service
+ .reviewChanges(params: .init(
+ changes: changes,
+ workspaceFolders: getWorkspaceFolders(workspace: workspace))
+ )
+ }
+
+ 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 ca7f7016..2274c13d 100644
--- a/Tool/Sources/HostAppActivator/HostAppActivator.swift
+++ b/Tool/Sources/HostAppActivator/HostAppActivator.swift
@@ -7,10 +7,16 @@ public let HostAppURL = locateHostBundleURL(url: Bundle.main.bundleURL)
public extension Notification.Name {
static let openSettingsWindowRequest = Notification
.Name("com.github.CopilotForXcode.OpenSettingsWindowRequest")
- static let openMCPSettingsWindowRequest = Notification
- .Name("com.github.CopilotForXcode.OpenMCPSettingsWindowRequest")
+ 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 launchHostAppMCPSettings() 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])
@@ -65,14 +68,44 @@ public func launchHostAppMCPSettings() throws {
_ = tryLaunchWithAppleScript()
DistributedNotificationCenter.default().postNotificationName(
- .openMCPSettingsWindowRequest,
+ .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 {
// If app is not running, launch it with the settings flag
- try launchHostAppWithArgs(args: ["--mcp"])
+ try launchHostAppWithArgs(args: ["--tools"])
+ }
+}
+
+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"])
}
}
@@ -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/AppState.swift b/Tool/Sources/Persist/AppState.swift
index 3a2834fb..e9e8424a 100644
--- a/Tool/Sources/Persist/AppState.swift
+++ b/Tool/Sources/Persist/AppState.swift
@@ -68,7 +68,7 @@ public class AppState {
public func update(key: String, value: T) {
queue.async {
- let userName = Status.currentUser() ?? ""
+ let userName = UserDefaults.shared.value(for: \.currentUserName)
self.initCacheForUserIfNeeded(userName)
self.cache[userName]![key] = JSONValue.convertToJSONValue(value)
self.saveCacheForUser(userName)
@@ -77,7 +77,7 @@ public class AppState {
public func get(key: String) -> JSONValue? {
return queue.sync {
- let userName = Status.currentUser() ?? ""
+ let userName = UserDefaults.shared.value(for: \.currentUserName)
initCacheForUserIfNeeded(userName)
return (self.cache[userName] ?? [:])[key]
}
@@ -88,7 +88,8 @@ public class AppState {
}
private func saveCacheForUser(_ userName: String? = nil) {
- if let user = userName ?? Status.currentUser(), !user.isEmpty { // save cache for non-empty user
+ let user = userName ?? UserDefaults.shared.value(for: \.currentUserName)
+ if !user.isEmpty { // save cache for non-empty user
let cacheFilePath = configFilePath(userName: user)
do {
let data = try JSONEncoder().encode(self.cache[user] ?? [:])
@@ -100,8 +101,8 @@ public class AppState {
}
private func initCacheForUserIfNeeded(_ userName: String? = nil) {
- if let user = userName ?? Status.currentUser(), !user.isEmpty,
- loadStatus[user] != true { // load cache for non-empty user
+ let user = userName ?? UserDefaults.shared.value(for: \.currentUserName)
+ if !user.isEmpty, loadStatus[user] != true { // load cache for non-empty user
self.loadStatus[user] = true
self.cache[user] = [:]
let cacheFilePath = configFilePath(userName: user)
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 c4296fc7..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")
@@ -243,6 +247,10 @@ public extension UserDefaultPreferenceKeys {
// MARK: - Chat
public extension UserDefaultPreferenceKeys {
+
+ var fontScale: PreferenceKey {
+ .init(defaultValue: 1.0, key: "FontScale")
+ }
var chatFontSize: PreferenceKey {
.init(defaultValue: 13, key: "ChatFontSize")
@@ -304,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")
@@ -312,6 +324,22 @@ public extension UserDefaultPreferenceKeys {
var autoAttachChatToXcode: PreferenceKey {
.init(defaultValue: true, key: "AutoAttachChatToXcode")
}
+
+ var enableFixError: PreferenceKey {
+ .init(defaultValue: true, key: "EnableFixError")
+ }
+
+ 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
@@ -587,4 +615,40 @@ public extension UserDefaultPreferenceKeys {
var verboseLoggingEnabled: PreferenceKey {
.init(defaultValue: false, key: "VerboseLoggingEnabled")
}
+
+ 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 dfaa5b67..9055c3c3 100644
--- a/Tool/Sources/Preferences/UserDefaults.swift
+++ b/Tool/Sources/Preferences/UserDefaults.swift
@@ -10,12 +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(
@@ -61,6 +72,10 @@ public extension UserDefaults {
weight: .regular
)))
)
+ shared.setupDefaultValue(
+ for: \.fontScale,
+ defaultValue: shared.value(for: \.fontScale)
+ )
}
}
@@ -74,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)
@@ -304,3 +321,35 @@ public extension UserDefaultsType {
}
}
+public extension UserDefaultsType {
+ // MARK: Dictionary Raw Representable
+
+ func value(
+ for keyPath: KeyPath
+ ) -> K.Value where K.Value: RawRepresentable, K.Value.RawValue == [String: Any] {
+ let key = UserDefaultPreferenceKeys()[keyPath: keyPath]
+ guard let rawValue = value(forKey: key.key) as? [String: Any] else {
+ return key.defaultValue
+ }
+ return K.Value(rawValue: rawValue) ?? key.defaultValue
+ }
+
+ func set(
+ _ value: K.Value,
+ for keyPath: KeyPath
+ ) where K.Value: RawRepresentable, K.Value.RawValue == [String: Any] {
+ let key = UserDefaultPreferenceKeys()[keyPath: keyPath]
+ set(value.rawValue, forKey: key.key)
+ }
+
+ func setupDefaultValue(
+ for keyPath: KeyPath,
+ defaultValue: K.Value? = nil
+ ) where K.Value: RawRepresentable, K.Value.RawValue == [String: Any] {
+ let key = UserDefaultPreferenceKeys()[keyPath: keyPath]
+ if value(forKey: key.key) == nil {
+ set(defaultValue?.rawValue ?? key.defaultValue.rawValue, forKey: key.key)
+ }
+ }
+}
+
diff --git a/Core/Sources/HostApp/SharedComponents/AdaptiveHelpLink.swift b/Tool/Sources/SharedUIComponents/AdaptiveHelpLink.swift
similarity index 84%
rename from Core/Sources/HostApp/SharedComponents/AdaptiveHelpLink.swift
rename to Tool/Sources/SharedUIComponents/AdaptiveHelpLink.swift
index 54ee7e9c..5e06037b 100644
--- a/Core/Sources/HostApp/SharedComponents/AdaptiveHelpLink.swift
+++ b/Tool/Sources/SharedUIComponents/AdaptiveHelpLink.swift
@@ -2,16 +2,16 @@ import SwiftUI
/// A small adaptive help link button that uses the native `HelpLink` on macOS 14+
/// and falls back to a styled question-mark button on earlier versions.
-struct AdaptiveHelpLink: View {
+public struct AdaptiveHelpLink: View {
let action: () -> Void
var controlSize: ControlSize = .small
- init(controlSize: ControlSize = .small, action: @escaping () -> Void) {
+ public init(controlSize: ControlSize = .small, action: @escaping () -> Void) {
self.controlSize = controlSize
self.action = action
}
- var body: some View {
+ public var body: some View {
Group {
if #available(macOS 14.0, *) {
HelpLink(action: action)
diff --git a/Tool/Sources/SharedUIComponents/Base/Colors.swift b/Tool/Sources/SharedUIComponents/Base/Colors.swift
index 2015102a..9ab89738 100644
--- a/Tool/Sources/SharedUIComponents/Base/Colors.swift
+++ b/Tool/Sources/SharedUIComponents/Base/Colors.swift
@@ -2,4 +2,44 @@ import SwiftUI
public extension Color {
static var hoverColor: Color { .gray.opacity(0.1) }
+
+ static var chatWindowBackgroundColor: Color { Color("ChatWindowBackgroundColor") }
+
+ static var successLightGreen: Color { Color("LightGreen") }
+
+ static var agentToolStatusDividerColor: Color { Color("AgentToolStatusDividerColor") }
+
+ static var agentToolStatusOutlineColor: Color { Color("AgentToolStatusOutlineColor") }
+}
+
+public var QuinarySystemFillColor: Color {
+ if #available(macOS 14.0, *) {
+ return Color(nsColor: .quinarySystemFill)
+ } else {
+ return Color("QuinarySystemFillColor")
+ }
+}
+
+public var QuaternarySystemFillColor: Color {
+ if #available(macOS 14.0, *) {
+ return Color(nsColor: .quaternarySystemFill)
+ } else {
+ return Color("QuaternarySystemFillColor")
+ }
+}
+
+public var TertiarySystemFillColor: Color {
+ if #available(macOS 14.0, *) {
+ return Color(nsColor: .tertiarySystemFill)
+ } else {
+ return Color("TertiarySystemFillColor")
+ }
+}
+
+public var SecondarySystemFillColor: Color {
+ if #available(macOS 14.0, *) {
+ return Color(nsColor: .secondarySystemFill)
+ } else {
+ return Color("SecondarySystemFillColor")
+ }
}
diff --git a/Tool/Sources/SharedUIComponents/Base/FileIcon.swift b/Tool/Sources/SharedUIComponents/Base/FileIcon.swift
index 92384e17..62a647d1 100644
--- a/Tool/Sources/SharedUIComponents/Base/FileIcon.swift
+++ b/Tool/Sources/SharedUIComponents/Base/FileIcon.swift
@@ -1,26 +1,119 @@
import Foundation
import SwiftUI
-
-public func drawFileIcon(_ file: URL?) -> Image {
- let defaultImage = Image(systemName: "doc.text")
-
- guard let file = file else { return defaultImage }
+@ViewBuilder
+public func drawFileIcon(_ file: URL?) -> some View {
+ let fileExtension = file?.pathExtension.lowercased() ?? ""
- let fileExtension = file.pathExtension.lowercased()
- if fileExtension == "swift" {
+ switch fileExtension {
+ case "swift":
if let nsImage = NSImage(named: "SwiftIcon") {
- return Image(nsImage: nsImage)
+ Image(nsImage: nsImage)
+ .resizable()
+ } else {
+ Image(systemName: "doc.text")
+ .resizable()
+ }
+ case "md":
+ Text("Mโ")
+ .scaledFont(size: 10, weight: .bold, design: .monospaced)
+ .foregroundColor(.indigo)
+ case "plist":
+ Image(systemName: "table")
+ .resizable()
+ case "xcconfig":
+ Image(systemName: "gearshape.2")
+ .resizable()
+ case "html":
+ Image(systemName: "chevron.left.slash.chevron.right")
+ .resizable()
+ .foregroundColor(.blue)
+ case "entitlements":
+ Image(systemName: "checkmark.seal.text.page")
+ .resizable()
+ .foregroundColor(.yellow)
+ case "sh":
+ Image(systemName: "terminal")
+ .resizable()
+ case "txt":
+ Image(systemName: "doc.plaintext")
+ .resizable()
+ case "c", "m", "mm":
+ Text("C")
+ .scaledFont(size: 12, weight: .bold, design: .monospaced)
+ .foregroundColor(.blue)
+ case "cpp":
+ Text("C++")
+ .scaledFont(size: 8, weight: .bold, design: .monospaced)
+ .foregroundColor(.blue)
+ case "h":
+ Text("h")
+ .scaledFont(size: 12, weight: .bold, design: .monospaced)
+ .foregroundColor(.blue)
+ case "xml":
+ Text("XML")
+ .scaledFont(size: 8, weight: .bold, design: .monospaced)
+ .foregroundColor(.orange)
+ case "yml", "yaml":
+ Text("YML")
+ .scaledFont(size: 8, weight: .bold, design: .monospaced)
+ .foregroundColor(.pink)
+ case "json":
+ Text("{}")
+ .scaledFont(size: 10, weight: .bold, design: .monospaced)
+ .foregroundColor(.red)
+ case "ts":
+ Text("TS")
+ .scaledFont(size: 10, weight: .bold, design: .monospaced)
+ .foregroundColor(.blue)
+ case "tsx":
+ Text("TSX")
+ .scaledFont(size: 8, weight: .bold, design: .monospaced)
+ .foregroundColor(.blue)
+ case "js":
+ Text("JS")
+ .scaledFont(size: 10, weight: .bold, design: .monospaced)
+ .foregroundColor(.yellow)
+ case "jsx":
+ Text("JSX")
+ .scaledFont(size: 8, weight: .bold, design: .monospaced)
+ .foregroundColor(.yellow)
+ case "css":
+ Text("CSS")
+ .scaledFont(size: 8, weight: .bold, design: .monospaced)
+ .foregroundColor(.purple)
+ case "py":
+ Text("PY")
+ .scaledFont(size: 10, weight: .bold, design: .monospaced)
+ .foregroundColor(.indigo)
+ case "xctestplan":
+ ZStack {
+ Text("P")
+ .scaledFont(size: 8, weight: .bold, design: .monospaced)
+ .foregroundColor(.blue)
+ RoundedRectangle(cornerRadius: 1.5)
+ .stroke(Color.blue, lineWidth: 1.5)
+ .scaledFrame(width: 10, height: 10)
+ .rotationEffect(.degrees(45))
}
+ default:
+ Image(systemName: "doc.text")
+ .resizable()
}
-
- return defaultImage
}
-public func drawFileIcon(_ file: URL?, isDirectory: Bool = false) -> Image {
+@ViewBuilder
+public func drawFileIcon(_ file: URL?, isDirectory: Bool = false) -> some View {
if isDirectory {
- return Image(systemName: "folder")
+ if file?.lastPathComponent == "xcassets" {
+ Image(systemName: "photo.on.rectangle.angled")
+ .resizable()
+ .foregroundColor(.blue)
+ } else {
+ Image(systemName: "folder")
+ .resizable()
+ }
} else {
- return drawFileIcon(file)
+ drawFileIcon(file)
}
}
diff --git a/Tool/Sources/SharedUIComponents/Base/HoverButtunStyle.swift b/Tool/Sources/SharedUIComponents/Base/HoverButtunStyle.swift
index e58b5b56..313346ad 100644
--- a/Tool/Sources/SharedUIComponents/Base/HoverButtunStyle.swift
+++ b/Tool/Sources/SharedUIComponents/Base/HoverButtunStyle.swift
@@ -5,24 +5,34 @@ public struct HoverButtonStyle: ButtonStyle {
@State private var isHovered: Bool
private var padding: CGFloat
private var hoverColor: Color
+ private var backgroundColor: Color
+ private var cornerRadius: CGFloat
- public init(isHovered: Bool = false, padding: CGFloat = 4, hoverColor: Color = .hoverColor) {
+ public init(
+ isHovered: Bool = false,
+ padding: CGFloat = 4,
+ hoverColor: Color = .hoverColor,
+ backgroundColor: Color = .clear,
+ cornerRadius: CGFloat = 4
+ ) {
self.isHovered = isHovered
self.padding = padding
self.hoverColor = hoverColor
+ self.backgroundColor = backgroundColor
+ self.cornerRadius = cornerRadius
}
public func makeBody(configuration: Configuration) -> some View {
configuration.label
- .padding(padding)
+ .scaledPadding(padding)
.background(
configuration.isPressed
? Color.gray.opacity(0.2)
: isHovered
? hoverColor
- : Color.clear
+ : backgroundColor
)
- .cornerRadius(4)
+ .cornerRadius(cornerRadius)
.onHover { hover in
isHovered = hover
}
diff --git a/Tool/Sources/SharedUIComponents/CollapsibleSearchField.swift b/Tool/Sources/SharedUIComponents/CollapsibleSearchField.swift
new file mode 100644
index 00000000..54edfe08
--- /dev/null
+++ b/Tool/Sources/SharedUIComponents/CollapsibleSearchField.swift
@@ -0,0 +1,118 @@
+import SwiftUI
+import AppKit
+
+public struct CollapsibleSearchField: View {
+ @Binding public var searchText: String
+ @Binding public var isExpanded: Bool
+ public let placeholderString: String
+
+ public init(
+ searchText: Binding,
+ isExpanded: Binding,
+ placeholderString: String = "Search..."
+ ) {
+ self._searchText = searchText
+ self._isExpanded = isExpanded
+ self.placeholderString = placeholderString
+ }
+
+ public var body: some View {
+ Group {
+ if isExpanded {
+ SearchFieldRepresentable(
+ searchText: $searchText,
+ isExpanded: $isExpanded,
+ placeholderString: placeholderString
+ )
+ .frame(width: 200, height: 24)
+ .transition(.opacity)
+ } else {
+ Button(action: {
+ isExpanded = true
+ }) {
+ Image(systemName: "magnifyingglass")
+ .font(.system(size: 13))
+ }
+ .buttonStyle(.plain)
+ .frame(height: 24)
+ .transition(.opacity)
+ }
+ }
+ }
+}
+
+private struct SearchFieldRepresentable: NSViewRepresentable {
+ @Binding var searchText: String
+ @Binding var isExpanded: Bool
+ let placeholderString: String
+
+ func makeNSView(context: Context) -> NSSearchField {
+ let searchField = NSSearchField()
+ searchField.placeholderString = placeholderString
+ searchField.delegate = context.coordinator
+ searchField.target = context.coordinator
+ searchField.action = #selector(Coordinator.searchFieldDidChange(_:))
+
+ // Make the magnifying glass clickable to collapse
+ if let cell = searchField.cell as? NSSearchFieldCell {
+ cell.searchButtonCell?.target = context.coordinator
+ cell.searchButtonCell?.action = #selector(Coordinator.magnifyingGlassClicked(_:))
+ }
+
+ return searchField
+ }
+
+ func updateNSView(_ nsView: NSSearchField, context: Context) {
+ if nsView.stringValue != searchText {
+ nsView.stringValue = searchText
+ }
+
+ context.coordinator.isExpanded = $isExpanded
+
+ // Auto-focus when expanded, only if not already first responder
+ if isExpanded && nsView.window?.firstResponder != nsView.currentEditor() {
+ DispatchQueue.main.async {
+ nsView.window?.makeFirstResponder(nsView)
+ }
+ }
+ }
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator(searchText: $searchText, isExpanded: $isExpanded)
+ }
+
+ class Coordinator: NSObject, NSSearchFieldDelegate, NSTextFieldDelegate {
+ @Binding var searchText: String
+ var isExpanded: Binding
+
+ init(searchText: Binding, isExpanded: Binding) {
+ _searchText = searchText
+ self.isExpanded = isExpanded
+ }
+
+ @objc func searchFieldDidChange(_ sender: NSSearchField) {
+ searchText = sender.stringValue
+ }
+
+ @objc func magnifyingGlassClicked(_ sender: Any) {
+ // Collapse when magnifying glass is clicked
+ DispatchQueue.main.async { [weak self] in
+ withAnimation(.easeInOut(duration: 0.2)) {
+ self?.isExpanded.wrappedValue = false
+ }
+ }
+ }
+
+ func controlTextDidEndEditing(_ obj: Notification) {
+ // Collapse search field when it loses focus and text is empty
+ if searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ DispatchQueue.main.async { [weak self] in
+ withAnimation(.easeInOut(duration: 0.2)) {
+ self?.isExpanded.wrappedValue = false
+ self?.searchText = ""
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/Tool/Sources/SharedUIComponents/ConditionalFontWeight.swift b/Tool/Sources/SharedUIComponents/ConditionalFontWeight.swift
index 55cc15c7..b09c94ba 100644
--- a/Tool/Sources/SharedUIComponents/ConditionalFontWeight.swift
+++ b/Tool/Sources/SharedUIComponents/ConditionalFontWeight.swift
@@ -8,11 +8,7 @@ public struct ConditionalFontWeight: ViewModifier {
}
public func body(content: Content) -> some View {
- if #available(macOS 13.0, *), weight != nil {
- content.fontWeight(weight)
- } else {
- content
- }
+ content.fontWeight(weight)
}
}
diff --git a/Tool/Sources/SharedUIComponents/CopilotIntroSheet.swift b/Tool/Sources/SharedUIComponents/CopilotIntroSheet.swift
index a077a320..ed39cd4f 100644
--- a/Tool/Sources/SharedUIComponents/CopilotIntroSheet.swift
+++ b/Tool/Sources/SharedUIComponents/CopilotIntroSheet.swift
@@ -27,7 +27,7 @@ struct CopilotIntroItem: View {
.renderingMode(.template)
.foregroundColor(.blue)
.scaledToFit()
- .frame(width: 28, height: 28)
+ .scaledFrame(width: 28, height: 28)
VStack(alignment: .leading, spacing: 5) {
Text(heading)
.font(.system(size: 11, weight: .bold))
diff --git a/Tool/Sources/SharedUIComponents/CopilotMessageHeader.swift b/Tool/Sources/SharedUIComponents/CopilotMessageHeader.swift
index 922ed55f..34494137 100644
--- a/Tool/Sources/SharedUIComponents/CopilotMessageHeader.swift
+++ b/Tool/Sources/SharedUIComponents/CopilotMessageHeader.swift
@@ -12,18 +12,17 @@ public struct CopilotMessageHeader: View {
ZStack {
Circle()
.stroke(Color(nsColor: .separatorColor), lineWidth: 1)
- .frame(width: 24, height: 24)
+ .scaledFrame(width: 24, height: 24)
Image("CopilotLogo")
.resizable()
.renderingMode(.template)
.scaledToFit()
- .frame(width: 12, height: 12)
+ .scaledFrame(width: 14, height: 14)
}
Text("GitHub Copilot")
- .font(.system(size: 13))
- .fontWeight(.semibold)
+ .scaledFont(size: 13, weight: .semibold)
.padding(.leading, 4)
Spacer()
diff --git a/Tool/Sources/SharedUIComponents/CopyButton.swift b/Tool/Sources/SharedUIComponents/CopyButton.swift
index 0e79a0b4..e705d183 100644
--- a/Tool/Sources/SharedUIComponents/CopyButton.swift
+++ b/Tool/Sources/SharedUIComponents/CopyButton.swift
@@ -28,11 +28,11 @@ public struct CopyButton: View {
}) {
Image(systemName: isCopied ? "checkmark.circle" : "doc.on.doc")
.resizable()
- .aspectRatio(contentMode: .fit)
- .frame(width: 14, height: 14)
+ .scaledToFit()
+ .scaledPadding(2)
+ .scaledFrame(width: 16, height: 16)
.foregroundColor(foregroundColor ?? .secondary)
.conditionalFontWeight(fontWeight)
- .padding(4)
}
.buttonStyle(HoverButtonStyle(padding: 0))
.help("Copy")
diff --git a/Core/Sources/HostApp/AdvancedSettings/CreateCustomCopilotFileView.swift b/Tool/Sources/SharedUIComponents/CreateCustomCopilotFileView.swift
similarity index 56%
rename from Core/Sources/HostApp/AdvancedSettings/CreateCustomCopilotFileView.swift
rename to Tool/Sources/SharedUIComponents/CreateCustomCopilotFileView.swift
index d3b45f07..2758a5cf 100644
--- a/Core/Sources/HostApp/AdvancedSettings/CreateCustomCopilotFileView.swift
+++ b/Tool/Sources/SharedUIComponents/CreateCustomCopilotFileView.swift
@@ -1,79 +1,78 @@
-import Client
import SwiftUI
-import XcodeInspector
+import ConversationServiceProvider
+import AppKitExtension
-struct CreateCustomCopilotFileView: View {
- var isOpen: Binding
- let promptType: PromptType
+public struct CreateCustomCopilotFileView: View {
+ public let promptType: PromptType
+ public let editorPluginVersion: String
+ public let getCurrentProjectURL: () async -> URL?
+ public let onSuccess: (String) -> Void
+ public let onError: (String) -> Void
@State private var fileName = ""
@State private var projectURL: URL?
@State private var fileAlreadyExists = false
- @Environment(\.toast) var toast
+ @Environment(\.dismiss) private var dismiss
- init(isOpen: Binding, promptType: PromptType) {
- self.isOpen = isOpen
+ public init(
+ promptType: PromptType,
+ editorPluginVersion: String,
+ getCurrentProjectURL: @escaping () async -> URL?,
+ onSuccess: @escaping (String) -> Void,
+ onError: @escaping (String) -> Void
+ ) {
self.promptType = promptType
+ self.editorPluginVersion = editorPluginVersion
+ self.getCurrentProjectURL = getCurrentProjectURL
+ self.onSuccess = onSuccess
+ self.onError = onError
}
- var body: some View {
- VStack(alignment: .leading, spacing: 0) {
- HStack(alignment: .center) {
- Button(action: { self.isOpen.wrappedValue = false }) {
- Image(systemName: "xmark.circle.fill")
- .foregroundStyle(.secondary)
- .padding()
+ public var body: some View {
+ Form {
+ VStack(alignment: .center, spacing: 20) {
+ HStack(alignment: .center) {
+ Spacer()
+ Text("Create \(promptType.displayName)").font(.headline)
+ Spacer()
+ AdaptiveHelpLink(action: openHelpLink)
}
- .buttonStyle(.plain)
- Text("Create \(promptType.displayName)")
- .font(.system(size: 13, weight: .bold))
- Spacer()
-
- AdaptiveHelpLink(action: openHelpLink)
- .padding()
- }
- .frame(height: 28)
- .background(Color(nsColor: .separatorColor))
-
- // Content
- VStack(alignment: .leading, spacing: 8) {
- Text("Enter the name of \(promptType.rawValue) file:")
- .font(.body)
-
- TextField("File name", text: $fileName)
- .textFieldStyle(.roundedBorder)
- .onSubmit {
- Task { await createPromptFile() }
+ // Content
+ VStack(alignment: .leading, spacing: 4) {
+ TextFieldsContainer {
+ TextField("File name", text: Binding(
+ get: { fileName },
+ set: { newValue in
+ fileName = newValue
+ updateFileExistence()
+ }
+ ))
+ .disableAutocorrection(true)
+ .textContentType(.none)
+ .onSubmit {
+ Task { await createPromptFile() }
+ }
}
- .onChange(of: fileName) { _ in
- updateFileExistence()
- }
-
- validationMessageView
- Spacer()
+ validationMessageView
+ }
- HStack(spacing: 12) {
+ HStack(spacing: 8) {
Spacer()
-
- Button("Cancel") {
- self.isOpen.wrappedValue = false
- }
- .buttonStyle(.bordered)
-
- Button("Create") {
- Task { await createPromptFile() }
- }
+ Button("Cancel", role: .cancel) { dismiss() }
+ Button("Create") { Task { await createPromptFile() } }
.buttonStyle(.borderedProminent)
.disabled(disableCreateButton)
+ .keyboardShortcut(.defaultAction)
}
}
- .padding(.vertical, 8)
- .padding(.horizontal, 20)
+ .textFieldStyle(.plain)
+ .multilineTextAlignment(.trailing)
+ .padding(20)
}
- .frame(width: 350, height: 160)
+ .frame(width: 350, height: 190)
.onAppear {
fileName = ""
Task { await resolveProjectURL() }
@@ -101,31 +100,35 @@ struct CreateCustomCopilotFileView: View {
.foregroundColor(.red)
.lineLimit(2)
.multilineTextAlignment(.leading)
+ .truncationMode(.middle)
.fixedSize(horizontal: false, vertical: true)
- .layoutPriority(1)
} else if trimmedFileName.isEmpty {
Image(systemName: "info.circle")
.foregroundColor(.secondary)
- Text("Enter a file name")
+ Text("Enter the name of \(promptType.rawValue) file")
.font(.caption)
.foregroundColor(.secondary)
} else {
+ Text("Location:")
+ .foregroundColor(.primary)
+ .padding(.leading, 10)
+ .layoutPriority(1)
Text(".github/\(promptType.directoryName)/\(trimmedFileName)\(promptType.fileExtension)")
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(2)
.multilineTextAlignment(.leading)
+ .truncationMode(.middle)
.fixedSize(horizontal: false, vertical: true)
- .layoutPriority(1)
}
}
- .transition(.opacity)
+ .padding(.horizontal, 2)
}
// MARK: - Actions / Helpers
private func openHelpLink() {
- if let url = URL(string: promptType.helpLink) {
+ if let url = URL(string: promptType.helpLink(editorPluginVersion: editorPluginVersion)) {
NSWorkspace.shared.open(url)
}
}
@@ -153,7 +156,7 @@ struct CreateCustomCopilotFileView: View {
private func createPromptFile() async {
guard let projectURL else {
await MainActor.run {
- toast("No active workspace found", .error)
+ onError("No active workspace found")
}
return
}
@@ -165,7 +168,7 @@ struct CreateCustomCopilotFileView: View {
if FileManager.default.fileExists(atPath: filePath.path) {
await MainActor.run {
self.fileAlreadyExists = true
- toast("\(promptType.displayName) '\(trimmedFileName)\(promptType.fileExtension)' already exists", .warning)
+ onError("\(promptType.displayName) '\(trimmedFileName)\(promptType.fileExtension)' already exists")
}
return
}
@@ -179,13 +182,13 @@ struct CreateCustomCopilotFileView: View {
try promptType.defaultTemplate.write(to: filePath, atomically: true, encoding: .utf8)
await MainActor.run {
- toast("Created \(promptType.rawValue) file '\(trimmedFileName)\(promptType.fileExtension)'", .info)
- NSWorkspace.shared.open(filePath)
- self.isOpen.wrappedValue = false
+ onSuccess("Created \(promptType.rawValue) file '\(trimmedFileName)\(promptType.fileExtension)'")
+ NSWorkspace.openFileInXcode(fileURL: filePath)
+ dismiss()
}
} catch {
await MainActor.run {
- toast("Failed to create \(promptType.rawValue) file: \(error)", .error)
+ onError("Failed to create \(promptType.rawValue) file: \(error)")
}
}
}
diff --git a/Tool/Sources/SharedUIComponents/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