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 1a6bb6c9..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: '15.3'
+ 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 136e2344..2a7f67ef 100644
--- a/.gitignore
+++ b/.gitignore
@@ -117,8 +117,10 @@ Core/Package.resolved
# Copilot language server
Server/node_modules/
+Server/dist
# Releases
/releases/
/release/
/appcast.xml
+
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 00000000..42463f66
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,264 @@
+# Changelog
+
+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.
+
+## 0.42.0 - September 3, 2025
+### Added
+- 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).
+- Use the current selection as chat context.
+- Add folders as chat context.
+- Shortcut to quickly fix errors in Xcode.
+- 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).
+- Use ↑/↓ keys to reuse previous chat context in the chat view.
+
+### Changed
+- Default chat mode is now set to “Agent”.
+
+### Fixed
+- Cannot copy url from Safari browser to chat view.
+
+## 0.41.0 - August 14, 2025
+### Added
+- Code review feature.
+- Chat: Support for new model GPT-5.
+- Agent mode: Added support for new tool to read web URL contents.
+- Support disabling MCP when it's disabled by policy.
+- Support for opening MCP logs directly from the MCP settings page.
+- OAuth support for remote GitHub MCP server.
+
+### Changed
+- Performance: Improved instant-apply speed for edit_file tool.
+
+### Fixed
+- Chat Agent repeatedly reverts its own changes when editing the same file.
+- Performance: Avoid chat panel being stuck when sending a large text for chat.
+
+## 0.40.0 - July 24, 2025
+### Added
+- Support disabling Agent mode when it's disabled by policy.
+
+## 0.39.0 - July 23, 2025
+### Fixed
+- Performance: Fixed a freezing issue in 'Add Context' view when opening large projects.
+- Login failed due to insufficient permissions on the .config folder.
+- Fixed an issue that setting changes like proxy config did not take effect.
+- Increased the timeout for ask mode to prevent response failures due to timeout.
+
+## 0.38.0 - June 30, 2025
+### Added
+- Support for Claude 4 in Chat.
+- Support for Copilot Vision (image attachments).
+- Support for remote MCP servers.
+
+### Changed
+- Automatically suggests a title for conversations created in agent mode.
+- Improved restoration of MCP tool status after Copilot restarts.
+- Reduced duplication of MCP server instances.
+
+### Fixed
+- Switching accounts now correctly refreshes the auth token and models.
+- Fixed file create/edit issues in agent mode.
+
+## 0.37.0 - June 18, 2025
+### Added
+- **Advanced** settings: Added option to configure **Custom Instructions** for GitHub Copilot during chat sessions.
+- **Advanced** settings: Added option to keep the chat window automatically attached to Xcode.
+
+### Changed
+- Enabled support for dragging-and-dropping files into the chat panel to provide context.
+
+### Fixed
+- "Add Context" menu didn’t show files in workspaces organized with Xcode’s group feature.
+- Chat didn’t respond when the workspace was in a system folder (like Desktop, Downloads, or Documents) and access permission hadn’t been granted.
+
+## 0.36.0 - June 4, 2025
+### Added
+- Introduced a new chat setting "**Response Language**" under **Advanced** settings to customize the natural language used in chat replies.
+- Enabled support for custom instructions defined in _.github/copilot-instructions.md_ within your workspace.
+- Added support for premium request handling.
+
+### Fixed
+- Performance: Improved UI responsiveness by lazily restoring chat history.
+- Performance: Fixed lagging issue when pasting large text into the chat input.
+- Performance: Improved project indexing performance.
+- Don't trigger / (slash) commands when pasting a file path into the chat input.
+- Adjusted terminal text styling to align with Xcode’s theme.
+
+## 0.35.0 - May 19, 2025
+### Added
+- Launched Agent Mode. Copilot will automatically use multiple requests to edit files, run terminal commands, and fix errors.
+- Introduced Model Context Protocol (MCP) support in Agent Mode, allowing you to configure MCP tools to extend capabilities.
+
+### Changed
+- Added a button to enable/disable referencing current file in conversations
+- Added an animated progress icon in the response section
+- Refined onboarding experience with updated instruction screens and welcome views
+- Improved conversation reliability with extended timeout limits for agent requests
+
+### Fixed
+- Addressed critical error handling issues in core functionality
+- Resolved UI inconsistencies with chat interface padding adjustments
+- Implemented custom certificate handling using system environment variables `NODE_EXTRA_CA_CERTS` and `NODE_TLS_REJECT_UNAUTHORIZED`, fixing network access issues
+
+## 0.34.0 - April 29, 2025
+### Added
+- Added support for new models in Chat: OpenAI GPT-4.1, o3 and o4-mini, Gemini 2.5 Pro
+
+### Changed
+- Switched default model to GPT-4.1 for new installations
+- Enhanced model selection interface
+
+### Fixed
+- Resolved critical error handling issues
+
+## 0.33.0 - April 17, 2025
+### Added
+- Added support for new models in Chat: Claude 3.7 Sonnet and GPT 4.5
+- Implemented @workspace context feature allowing questions about the entire codebase in Copilot Chat
+
+### Changed
+- Simplified access to Copilot Chat from the Copilot for Xcode app with a single click
+- Enhanced instructions for granting background permissions
+
+### Fixed
+- Resolved false alarms for sign-in and free plan limit notifications
+- Improved app launch performance
+- Fixed workspace and context update issues
+
+## 0.32.0 - March 11, 2025 (General Availability)
+### Added
+- Implemented model picker for selecting LLM model in chat
+- Introduced new `/releaseNotes` slash command for accessing release information
+
+### Changed
+- Improved focus handling with automatic switching between chat text field and file search bar
+- Enhanced keyboard navigation support for file picker in chat context
+- Refined instructions for granting accessibility and extension permissions
+- Enhanced accessibility compliance for the chat window
+- Redesigned notification and status bar menu styles for better usability
+
+### Fixed
+- Resolved compatibility issues with macOS 12/13/14
+- Fixed handling of invalid workspace switch event '/'
+- Corrected chat attachment file picker to respect workspace scope
+- Improved icon display consistency across different themes
+- Added support for previously unsupported file types (.md, .txt) in attachments
+- Adjusted incorrect margins in chat window UI
+
+## 0.31.0 - February 11, 2025 (Public Preview)
+### Added
+- Added Copilot Chat support
+- Added GitHub Freeplan support
+- Implemented conversation and chat history management across multiple Xcode instances
+- Introduced multi-file context support for comprehensive code understanding
+- Added slash commands for specialized operations
diff --git a/CommunicationBridge/ServiceDelegate.swift b/CommunicationBridge/ServiceDelegate.swift
index e34dee91..6dbb0e0f 100644
--- a/CommunicationBridge/ServiceDelegate.swift
+++ b/CommunicationBridge/ServiceDelegate.swift
@@ -136,28 +136,100 @@ actor ExtensionServiceLauncher {
isLaunching = true
Logger.communicationBridge.info("Launching extension service app.")
-
- NSWorkspace.shared.openApplication(
- at: appURL,
- configuration: {
- let configuration = NSWorkspace.OpenConfiguration()
- configuration.createsNewApplicationInstance = false
- configuration.addsToRecentItems = false
- configuration.activates = false
- return configuration
- }()
- ) { app, error in
- if let error = error {
- Logger.communicationBridge.error(
- "Failed to launch extension service app: \(error)"
- )
- } else {
- Logger.communicationBridge.info(
- "Finished launching extension service app."
- )
+
+ // First check if the app is already running
+ if let runningApp = NSWorkspace.shared.runningApplications.first(where: {
+ $0.bundleIdentifier == appIdentifier
+ }) {
+ Logger.communicationBridge.info("Extension service app already running with PID: \(runningApp.processIdentifier)")
+ self.application = runningApp
+ self.isLaunching = false
+ return
+ }
+
+ // Implement a retry mechanism with exponential backoff
+ Task {
+ var retryCount = 0
+ let maxRetries = 3
+ var success = false
+
+ while !success && retryCount < maxRetries {
+ do {
+ // Add a delay between retries with exponential backoff
+ if retryCount > 0 {
+ let delaySeconds = pow(2.0, Double(retryCount - 1))
+ Logger.communicationBridge.info("Retrying launch after \(delaySeconds) seconds (attempt \(retryCount + 1) of \(maxRetries))")
+ try await Task.sleep(nanoseconds: UInt64(delaySeconds * 1_000_000_000))
+ }
+
+ // Use a task-based approach for launching with timeout
+ let launchTask = Task { () -> NSRunningApplication? in
+ return await withCheckedContinuation { continuation in
+ NSWorkspace.shared.openApplication(
+ at: appURL,
+ configuration: {
+ let configuration = NSWorkspace.OpenConfiguration()
+ configuration.createsNewApplicationInstance = false
+ configuration.addsToRecentItems = false
+ configuration.activates = false
+ return configuration
+ }()
+ ) { app, error in
+ if error != nil {
+ continuation.resume(returning: nil)
+ } else {
+ continuation.resume(returning: app)
+ }
+ }
+ }
+ }
+
+ // Set a timeout for the launch operation
+ let timeoutTask = Task {
+ try await Task.sleep(nanoseconds: 10_000_000_000) // 10 seconds
+ return
+ }
+
+ // Wait for either the launch or the timeout
+ let app = try await withTaskCancellationHandler {
+ try await launchTask.value ?? nil
+ } onCancel: {
+ launchTask.cancel()
+ }
+
+ // Cancel the timeout task
+ timeoutTask.cancel()
+
+ if let app = app {
+ // Success!
+ self.application = app
+ success = true
+ break
+ } else {
+ // App is nil, retry
+ retryCount += 1
+ Logger.communicationBridge.info("Launch attempt \(retryCount) failed, app is nil")
+ }
+ } catch {
+ retryCount += 1
+ Logger.communicationBridge.error("Error during launch attempt \(retryCount): \(error.localizedDescription)")
+ }
}
-
- self.application = app
+
+ // Double-check we have a valid application
+ if !success && self.application == nil {
+ // After all retries, check once more if the app is running (it might have launched but we missed the callback)
+ if let runningApp = NSWorkspace.shared.runningApplications.first(where: {
+ $0.bundleIdentifier == appIdentifier
+ }) {
+ Logger.communicationBridge.info("Found running extension service after retries: \(runningApp.processIdentifier)")
+ self.application = runningApp
+ success = true
+ } else {
+ Logger.communicationBridge.info("Failed to launch extension service after \(maxRetries) attempts")
+ }
+ }
+
self.isLaunching = false
}
}
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 56e21c33..d232491a 100644
--- a/Copilot for Xcode.xcodeproj/project.pbxproj
+++ b/Copilot for Xcode.xcodeproj/project.pbxproj
@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
- objectVersion = 56;
+ objectVersion = 70;
objects = {
/* Begin PBXBuildFile section */
@@ -11,12 +11,15 @@
3ABBEA2B2C8BA00300C61D61 /* copilot-language-server-arm64 in Resources */ = {isa = PBXBuildFile; fileRef = 3ABBEA2A2C8BA00300C61D61 /* copilot-language-server-arm64 */; };
3ABBEA2C2C8BA00800C61D61 /* copilot-language-server-arm64 in Resources */ = {isa = PBXBuildFile; fileRef = 3ABBEA2A2C8BA00300C61D61 /* copilot-language-server-arm64 */; };
3ABBEA2D2C8BA00B00C61D61 /* copilot-language-server in Resources */ = {isa = PBXBuildFile; fileRef = 3ABBEA282C8B9FE100C61D61 /* copilot-language-server */; };
+ 3E5DB7502D6B8FA500418952 /* ReleaseNotes.md in Resources */ = {isa = PBXBuildFile; fileRef = 3E5DB74F2D6B88EE00418952 /* ReleaseNotes.md */; };
424ACA212CA4697200FA20F2 /* Credits.rtf in Resources */ = {isa = PBXBuildFile; fileRef = 424ACA202CA4697200FA20F2 /* Credits.rtf */; };
427C63282C6E868B000E557C /* OpenSettingsCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427C63272C6E868B000E557C /* OpenSettingsCommand.swift */; };
5EC511E32C90CE7400632BAB /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C8189B1D2938973000C9DCDA /* Assets.xcassets */; };
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 */; };
@@ -187,10 +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 = ""; };
@@ -253,6 +259,10 @@
C8F103292A7A365000D28F4F /* launchAgent.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = launchAgent.plist; sourceTree = ""; };
/* End PBXFileReference section */
+/* Begin PBXFileSystemSynchronizedRootGroup section */
+ 9E6A029A2DBDF64200AB6BD5 /* Server */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Server; sourceTree = SOURCE_ROOT; };
+/* End PBXFileSystemSynchronizedRootGroup section */
+
/* Begin PBXFrameworksBuildPhase section */
C81458892939EFDC00135263 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
@@ -324,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 */,
@@ -345,6 +357,7 @@
C8189B0D2938972F00C9DCDA = {
isa = PBXGroup;
children = (
+ 3E5DB74F2D6B88EE00418952 /* ReleaseNotes.md */,
C887BC832965D96000931567 /* DEVELOPMENT.md */,
C8520308293D805800460097 /* README.md */,
C8F103292A7A365000D28F4F /* launchAgent.plist */,
@@ -354,6 +367,7 @@
C81458AE293A009800135263 /* Config.debug.xcconfig */,
C8CD828229B88006008D044D /* TestPlan.xctestplan */,
C828B27D2B1F241500E7612A /* ExtensionPoint.appextensionpoint */,
+ 9E6A029A2DBDF64200AB6BD5 /* Server */,
C81D181E2A1B509B006C1B70 /* Tool */,
C8189B282938979000C9DCDA /* Core */,
C8189B182938972F00C9DCDA /* Copilot for Xcode */,
@@ -678,6 +692,7 @@
C861E6152994F6080056CB02 /* Assets.xcassets in Resources */,
3ABBEA2D2C8BA00B00C61D61 /* copilot-language-server in Resources */,
C81291D72994FE6900196E12 /* Main.storyboard in Resources */,
+ 3E5DB7502D6B8FA500418952 /* ReleaseNotes.md in Resources */,
5EC511E42C90CE9800632BAB /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -696,24 +711,21 @@
/* Begin PBXShellScriptBuildPhase section */
3A60421A2C8955710006B34C /* ShellScript */ = {
isa = PBXShellScriptBuildPhase;
+ alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
- "$(SRCROOT)/Server/package.json",
- "$(SRCROOT)/Server/package-lock.json",
);
outputFileListPaths = (
);
outputPaths = (
- "$(SRCROOT)/Server/node_modules/@github/copilot-language-server/native/darwin-x64/copilot-language-server",
- "$(SRCROOT)/Server/node_modules/@github/copilot-language-server/native/darwin-arm64/copilot-language-server-arm64",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
- shellScript = "npm -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";
+ 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 */
@@ -728,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 */,
@@ -831,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;
@@ -860,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;
@@ -922,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;
@@ -977,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;
@@ -1008,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;
@@ -1042,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)";
@@ -1058,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;
@@ -1073,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;
@@ -1103,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)";
@@ -1137,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)";
@@ -1158,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;
@@ -1179,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.xcodeproj/xcshareddata/xcschemes/ExtensionService.xcscheme b/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/ExtensionService.xcscheme
index c0e9b79f..f672cd16 100644
--- a/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/ExtensionService.xcscheme
+++ b/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/ExtensionService.xcscheme
@@ -50,6 +50,18 @@
reference = "container:Pro/ProTestPlan.xctestplan">
+
+
+
+
+
+
NSView { return NSVisualEffectView() }
@@ -12,7 +14,159 @@ struct VisualEffect: NSViewRepresentable {
}
class AppDelegate: NSObject, NSApplicationDelegate {
- func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool { true }
+ private var permissionAlertShown = false
+
+ // Launch modes supported by the app
+ enum LaunchMode {
+ case chat
+ case settings
+ case tools
+ case toolsAutoApprove
+ case byok
+ }
+
+ func applicationDidFinishLaunching(_ notification: Notification) {
+ checkBackgroundPermissions()
+
+ let launchMode = determineLaunchMode()
+ handleLaunchMode(launchMode)
+ }
+
+ func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
+ checkBackgroundPermissions()
+
+ let launchMode = determineLaunchMode()
+ handleLaunchMode(launchMode)
+ return true
+ }
+
+ // MARK: - Helper Methods
+
+ private func determineLaunchMode() -> LaunchMode {
+ let launchArgs = CommandLine.arguments
+ if launchArgs.contains("--settings") {
+ return .settings
+ } else if launchArgs.contains("--tools") {
+ return .tools
+ } else if launchArgs.contains("--tools-auto-approve") {
+ return .toolsAutoApprove
+ } else if launchArgs.contains("--byok") {
+ return .byok
+ } else {
+ return .chat
+ }
+ }
+
+ private func handleLaunchMode(_ mode: LaunchMode) {
+ switch mode {
+ case .settings:
+ openSettings()
+ case .tools:
+ openToolsSettings()
+ case .toolsAutoApprove:
+ openToolsSettingsAutoApprove()
+ case .byok:
+ openBYOKSettings()
+ case .chat:
+ openChat()
+ }
+ }
+
+ private func openSettings() {
+ DispatchQueue.main.async {
+ activateAndOpenSettings()
+ }
+ }
+
+ private func openChat() {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+ Task {
+ let service = try? getService()
+ try? await service?.openChat()
+ }
+ }
+ }
+
+ private func openToolsSettings() {
+ DispatchQueue.main.async {
+ activateAndOpenSettings()
+ hostAppStore.send(.setActiveTab(.tools))
+ }
+ }
+
+ private func openToolsSettingsAutoApprove() {
+ DispatchQueue.main.async {
+ activateAndOpenSettings()
+ hostAppStore.send(.setActiveTab(.tools))
+ hostAppStore.send(.setActiveToolsSubTab(.AutoApprove))
+ }
+ }
+
+ private func openBYOKSettings() {
+ DispatchQueue.main.async {
+ activateAndOpenSettings()
+ hostAppStore.send(.setActiveTab(.byok))
+ }
+ }
+
+ private func checkBackgroundPermissions() {
+ Task {
+ // Direct check of permission status
+ let launchAgentManager = LaunchAgentManager()
+ let isPermissionGranted = await launchAgentManager.isBackgroundPermissionGranted()
+
+ if !isPermissionGranted {
+ // Only show alert if permission isn't granted
+ await MainActor.run {
+ if !self.permissionAlertShown {
+ showBackgroundPermissionAlert()
+ self.permissionAlertShown = true
+ }
+ }
+ } else {
+ // Permission is granted, reset flag
+ await MainActor.run {
+ self.permissionAlertShown = false
+ }
+ }
+ }
+ }
+
+ // MARK: - Application Termination
+
+ func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
+ // Immediately terminate extension service if it's running
+ if let extensionService = NSWorkspace.shared.runningApplications.first(where: {
+ $0.bundleIdentifier == "\(Bundle.main.bundleIdentifier!).ExtensionService"
+ }) {
+ extensionService.terminate()
+ }
+
+ // Start cleanup in background without waiting
+ Task {
+ _ = Task {
+ let service = try? getService()
+ try? await service?.quitService()
+ }
+
+ // Wait just a tiny bit to allow cleanup to start
+ try? await Task.sleep(nanoseconds: 100_000_000) // 100ms
+
+ DispatchQueue.main.async {
+ NSApp.reply(toApplicationShouldTerminate: true)
+ }
+ }
+
+ return .terminateLater
+ }
+
+ func applicationWillTerminate(_ notification: Notification) {
+ if let extensionService = NSWorkspace.shared.runningApplications.first(where: {
+ $0.bundleIdentifier == "\(Bundle.main.bundleIdentifier!).ExtensionService"
+ }) {
+ extensionService.terminate()
+ }
+ }
}
class AppUpdateCheckerDelegate: UpdateCheckerDelegate {
@@ -28,22 +182,96 @@ class AppUpdateCheckerDelegate: UpdateCheckerDelegate {
@main
struct CopilotForXcodeApp: App {
@NSApplicationDelegateAdaptor private var appDelegate: AppDelegate
+
+ init() {
+ UserDefaults.setupDefaultSettings()
+
+ Task {
+ await hostAppStore
+ .send(.general(.setupLaunchAgentIfNeeded))
+ .finish()
+ }
+
+ DistributedNotificationCenter.default().addObserver(
+ forName: .openSettingsWindowRequest,
+ object: nil,
+ queue: .main
+ ) { _ in
+ DispatchQueue.main.async {
+ activateAndOpenSettings()
+ }
+ }
+
+ DistributedNotificationCenter.default().addObserver(
+ 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(.tools))
+ hostAppStore.send(.setActiveToolsSubTab(.AutoApprove))
+ }
+ }
+
+ DistributedNotificationCenter.default().addObserver(
+ forName: .openBYOKSettingsWindowRequest,
+ object: nil,
+ queue: .main
+ ) { _ in
+ DispatchQueue.main.async {
+ activateAndOpenSettings()
+ 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 {
- WindowGroup {
- TabContainer()
- .frame(minWidth: 800, minHeight: 600)
- .background(VisualEffect().ignoresSafeArea())
- .onAppear {
- UserDefaults.setupDefaultSettings()
- }
- .copilotIntroSheet()
- .environment(\.updateChecker, UpdateChecker(
- hostBundle: Bundle.main,
- checkerDelegate: AppUpdateCheckerDelegate()
- ))
+ WithPerceptionTracking {
+ Settings {
+ TabContainer()
+ .frame(minWidth: 800, minHeight: 600)
+ .background(VisualEffect().ignoresSafeArea())
+ .environment(\.updateChecker, UpdateChecker(
+ hostBundle: Bundle.main,
+ checkerDelegate: AppUpdateCheckerDelegate()
+ ))
+ }
}
}
}
+@MainActor
+func activateAndOpenSettings() {
+ NSApp.activate(ignoringOtherApps: true)
+ if #available(macOS 14.0, *) {
+ let environment = SettingsEnvironment()
+ environment.open()
+ } else {
+ NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
+ }
+}
+
var isPreview: Bool { ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" }
diff --git a/Copilot for Xcode/Assets.xcassets/ChatIcon.imageset/ChatIcon.svg b/Copilot for Xcode/Assets.xcassets/ChatIcon.imageset/ChatIcon.svg
new file mode 100644
index 00000000..74239992
--- /dev/null
+++ b/Copilot for Xcode/Assets.xcassets/ChatIcon.imageset/ChatIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/Copilot for Xcode/Assets.xcassets/ChatIcon.imageset/Contents.json b/Copilot for Xcode/Assets.xcassets/ChatIcon.imageset/Contents.json
new file mode 100644
index 00000000..329dae48
--- /dev/null
+++ b/Copilot for Xcode/Assets.xcassets/ChatIcon.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ {
+ "filename" : "ChatIcon.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "preserves-vector-representation" : true
+ }
+}
diff --git a/Copilot for Xcode/Assets.xcassets/Color.colorset/Contents.json b/Copilot for Xcode/Assets.xcassets/Color.colorset/Contents.json
new file mode 100644
index 00000000..22c4bb0a
--- /dev/null
+++ b/Copilot for Xcode/Assets.xcassets/Color.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "1.000",
+ "green" : "1.000",
+ "red" : "1.000"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "1.000",
+ "green" : "1.000",
+ "red" : "1.000"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Copilot for Xcode/Assets.xcassets/CopilotError.imageset/Contents.json b/Copilot for Xcode/Assets.xcassets/CopilotError.imageset/Contents.json
new file mode 100644
index 00000000..78e08e6e
--- /dev/null
+++ b/Copilot for Xcode/Assets.xcassets/CopilotError.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ {
+ "filename" : "CopilotError.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "preserves-vector-representation" : true
+ }
+}
diff --git a/Copilot for Xcode/Assets.xcassets/CopilotError.imageset/CopilotError.svg b/Copilot for Xcode/Assets.xcassets/CopilotError.imageset/CopilotError.svg
new file mode 100644
index 00000000..ad107456
--- /dev/null
+++ b/Copilot for Xcode/Assets.xcassets/CopilotError.imageset/CopilotError.svg
@@ -0,0 +1,18 @@
+
diff --git a/Copilot for Xcode/Assets.xcassets/CopilotIssue.imageset/Contents.json b/Copilot for Xcode/Assets.xcassets/CopilotIssue.imageset/Contents.json
index 8ad86a73..9a465b02 100644
--- a/Copilot for Xcode/Assets.xcassets/CopilotIssue.imageset/Contents.json
+++ b/Copilot for Xcode/Assets.xcassets/CopilotIssue.imageset/Contents.json
@@ -8,5 +8,8 @@
"info" : {
"author" : "xcode",
"version" : 1
+ },
+ "properties" : {
+ "preserves-vector-representation" : true
}
}
diff --git a/Copilot for Xcode/Assets.xcassets/DangerBackgroundColor.colorset/Contents.json b/Copilot for Xcode/Assets.xcassets/DangerBackgroundColor.colorset/Contents.json
new file mode 100644
index 00000000..38242f14
--- /dev/null
+++ b/Copilot for Xcode/Assets.xcassets/DangerBackgroundColor.colorset/Contents.json
@@ -0,0 +1,20 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "display-p3",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0xF4",
+ "green" : "0xF3",
+ "red" : "0xFD"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Copilot for Xcode/Assets.xcassets/DangerForegroundColor.colorset/Contents.json b/Copilot for Xcode/Assets.xcassets/DangerForegroundColor.colorset/Contents.json
new file mode 100644
index 00000000..db248f82
--- /dev/null
+++ b/Copilot for Xcode/Assets.xcassets/DangerForegroundColor.colorset/Contents.json
@@ -0,0 +1,20 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0x1C",
+ "green" : "0x0E",
+ "red" : "0xB1"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Copilot for Xcode/Assets.xcassets/DangerStrokeColor.colorset/Contents.json b/Copilot for Xcode/Assets.xcassets/DangerStrokeColor.colorset/Contents.json
new file mode 100644
index 00000000..5fbecf46
--- /dev/null
+++ b/Copilot for Xcode/Assets.xcassets/DangerStrokeColor.colorset/Contents.json
@@ -0,0 +1,20 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0xB2",
+ "green" : "0xAC",
+ "red" : "0xEE"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Copilot for Xcode/Assets.xcassets/GroupBoxBackgroundColor.colorset/Contents.json b/Copilot for Xcode/Assets.xcassets/GroupBoxBackgroundColor.colorset/Contents.json
new file mode 100644
index 00000000..f7add95c
--- /dev/null
+++ b/Copilot for Xcode/Assets.xcassets/GroupBoxBackgroundColor.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.920",
+ "green" : "0.910",
+ "red" : "0.910"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.250",
+ "green" : "0.250",
+ "red" : "0.250"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Copilot for Xcode/Assets.xcassets/GroupBoxStrokeColor.colorset/Contents.json b/Copilot for Xcode/Assets.xcassets/GroupBoxStrokeColor.colorset/Contents.json
new file mode 100644
index 00000000..35b93a68
--- /dev/null
+++ b/Copilot for Xcode/Assets.xcassets/GroupBoxStrokeColor.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.900",
+ "green" : "0.900",
+ "red" : "0.900"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "0.080",
+ "blue" : "1.000",
+ "green" : "1.000",
+ "red" : "1.000"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Copilot for Xcode/Assets.xcassets/Model.imageset/Contents.json b/Copilot for Xcode/Assets.xcassets/Model.imageset/Contents.json
new file mode 100644
index 00000000..0923b9bd
--- /dev/null
+++ b/Copilot for Xcode/Assets.xcassets/Model.imageset/Contents.json
@@ -0,0 +1,16 @@
+{
+ "images" : [
+ {
+ "filename" : "ai-model-16.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "preserves-vector-representation" : true,
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/Copilot for Xcode/Assets.xcassets/Model.imageset/ai-model-16.svg b/Copilot for Xcode/Assets.xcassets/Model.imageset/ai-model-16.svg
new file mode 100644
index 00000000..8b7c28e2
--- /dev/null
+++ b/Copilot for Xcode/Assets.xcassets/Model.imageset/ai-model-16.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Copilot for Xcode/Assets.xcassets/QuaternarySystemFillColor.colorset/Contents.json b/Copilot for Xcode/Assets.xcassets/QuaternarySystemFillColor.colorset/Contents.json
new file mode 100644
index 00000000..df9ac298
--- /dev/null
+++ b/Copilot for Xcode/Assets.xcassets/QuaternarySystemFillColor.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0xF7",
+ "green" : "0xF7",
+ "red" : "0xF7"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0x09",
+ "green" : "0x09",
+ "red" : "0x09"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Copilot for Xcode/Assets.xcassets/QuinarySystemFillColor.colorset/Contents.json b/Copilot for Xcode/Assets.xcassets/QuinarySystemFillColor.colorset/Contents.json
new file mode 100644
index 00000000..fa0a3215
--- /dev/null
+++ b/Copilot for Xcode/Assets.xcassets/QuinarySystemFillColor.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" : "0x07",
+ "green" : "0x07",
+ "red" : "0x07"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Copilot for Xcode/Assets.xcassets/SecondarySystemFillColor.colorset/Contents.json b/Copilot for Xcode/Assets.xcassets/SecondarySystemFillColor.colorset/Contents.json
new file mode 100644
index 00000000..50c00cb2
--- /dev/null
+++ b/Copilot for Xcode/Assets.xcassets/SecondarySystemFillColor.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0xE6",
+ "green" : "0xE6",
+ "red" : "0xE6"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0x14",
+ "green" : "0x14",
+ "red" : "0x14"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Copilot for Xcode/Assets.xcassets/TertiarySystemFillColor.colorset/Contents.json b/Copilot for Xcode/Assets.xcassets/TertiarySystemFillColor.colorset/Contents.json
new file mode 100644
index 00000000..731810c3
--- /dev/null
+++ b/Copilot for Xcode/Assets.xcassets/TertiarySystemFillColor.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0xF2",
+ "green" : "0xF2",
+ "red" : "0xF2"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0x0D",
+ "green" : "0x0D",
+ "red" : "0x0D"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Copilot for Xcode/Assets.xcassets/ToolTitleHighlightBgColor.colorset/Contents.json b/Copilot for Xcode/Assets.xcassets/ToolTitleHighlightBgColor.colorset/Contents.json
new file mode 100644
index 00000000..ce478f39
--- /dev/null
+++ b/Copilot for Xcode/Assets.xcassets/ToolTitleHighlightBgColor.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "1.000",
+ "green" : "1.000",
+ "red" : "1.000"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.250",
+ "green" : "0.250",
+ "red" : "0.250"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Copilot for Xcode/Credits.rtf b/Copilot for Xcode/Credits.rtf
index c2bc880a..941fbb70 100644
--- a/Copilot for Xcode/Credits.rtf
+++ b/Copilot for Xcode/Credits.rtf
@@ -163,7 +163,7 @@ SOFTWARE.\
\
\
Dependency: github.com/apple/swift-syntax\
-Version: 509.0.2\
+Version: 510.0.3\
License Content:\
Apache License\
Version 2.0, January 2004\
@@ -1761,7 +1761,7 @@ License Content:\
\
\
Dependency: github.com/ChimeHQ/JSONRPC\
-Version: 0.6.0\
+Version: 0.9.0\
License Content:\
BSD 3-Clause License\
\
@@ -1795,7 +1795,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\
\
\
Dependency: github.com/ChimeHQ/LanguageServerProtocol\
-Version: 0.8.0\
+Version: 0.13.3\
License Content:\
BSD 3-Clause License\
\
@@ -2611,7 +2611,7 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI
\
\
Dependency: github.com/ChimeHQ/LanguageClient\
-Version: 0.3.1\
+Version: 0.8.2\
License Content:\
BSD 3-Clause License\
\
@@ -2645,7 +2645,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\
\
\
Dependency: github.com/ChimeHQ/ProcessEnv\
-Version: 0.3.1\
+Version: 1.0.1\
License Content:\
BSD 3-Clause License\
\
@@ -3242,4 +3242,197 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\
SOFTWARE.\
\
\
+Dependency: https://github.com/stephencelis/SQLite.swift\
+Version: 0.15.3\
+License Content:\
+MIT License\
+\
+Copyright (c) 2014-2015 Stephen Celis ()\
+\
+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/microsoft/monaco-editor\
+Version: 0.52.2\
+License Content:\
+The MIT License (MIT)\
+\
+Copyright (c) 2016 - present Microsoft Corporation\
+\
+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/xtermjs/xterm.js\
+Version: @xterm/addon-fit@0.10.0, @xterm/xterm@5.5.0\
+License Content:\
+The MIT License (MIT)\
+\
+Copyright (c) 2017-2019, The xterm.js authors (https://github.com/xtermjs/xterm.js)\
+Copyright (c) 2014-2016, SourceLair Private Company (https://www.sourcelair.com)\
+Copyright (c) 2012-2013, Christopher Jeffrey (https://github.com/chjj/)\
+\
+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/scinfu/SwiftSoup\
+Version: 2.9.6\
+License Content:\
+The MIT License\
+\
+Copyright (c) 2009-2025 Jonathan Hedley \
+Swift port copyright (c) 2016-2025 Nabil Chatbi\
+\
+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/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 6cf84fba..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,8 +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
@@ -74,6 +75,7 @@ let package = Package(
dependencies: [
"SuggestionWidget",
"SuggestionService",
+ "SuggestionInjector",
"ChatService",
"PromptToCodeService",
"ConversationTab",
@@ -94,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"),
@@ -121,6 +124,7 @@ let package = Package(
"Client",
"LaunchAgentManager",
"GitHubCopilotViewModel",
+ "UpdateChecker",
.product(name: "SuggestionProvider", package: "Tool"),
.product(name: "Toast", package: "Tool"),
.product(name: "SharedUIComponents", package: "Tool"),
@@ -131,6 +135,8 @@ let package = Package(
.product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
.product(name: "KeyboardShortcuts", package: "KeyboardShortcuts"),
.product(name: "GitHubCopilotService", package: "Tool"),
+ .product(name: "Persist", package: "Tool"),
+ .product(name: "UserDefaultsObserver", package: "Tool"),
]),
// MARK: - Suggestion Service
@@ -170,6 +176,7 @@ let package = Package(
.target(
name: "ChatService",
dependencies: [
+ "PersistMiddleware",
.product(name: "AppMonitoring", package: "Tool"),
.product(name: "Parsing", package: "swift-parsing"),
.product(name: "ChatAPIService", package: "Tool"),
@@ -177,12 +184,27 @@ let package = Package(
.product(name: "AXHelper", package: "Tool"),
.product(name: "ConversationServiceProvider", package: "Tool"),
.product(name: "GitHubCopilotService", package: "Tool"),
+ .product(name: "Workspace", package: "Tool"),
+ .product(name: "Terminal", package: "Tool"),
+ .product(name: "SystemUtils", package: "Tool"),
+ .product(name: "AppKitExtension", package: "Tool"),
+ .product(name: "WebContentExtractor", package: "Tool"),
+ .product(name: "GitHelper", 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",
+ dependencies: ["ChatService"]
+ ),
.target(
name: "ConversationTab",
dependencies: [
"ChatService",
+ "GitHubCopilotViewModel",
.product(name: "SharedUIComponents", package: "Tool"),
.product(name: "ChatAPIService", package: "Tool"),
.product(name: "Logger", package: "Tool"),
@@ -191,7 +213,8 @@ let package = Package(
.product(name: "Cache", package: "Tool"),
.product(name: "MarkdownUI", package: "swift-markdown-ui"),
.product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
- .product(name: "SwiftUIFlowLayout", package: "swiftui-flow-layout")
+ .product(name: "SwiftUIFlowLayout", package: "swiftui-flow-layout"),
+ .product(name: "Persist", package: "Tool")
]
),
@@ -200,9 +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"),
@@ -211,6 +237,7 @@ let package = Package(
.product(name: "ChatTab", package: "Tool"),
.product(name: "Logger", package: "Tool"),
.product(name: "CustomAsyncAlgorithms", package: "Tool"),
+ .product(name: "HostAppActivator", package: "Tool"),
.product(name: "AsyncAlgorithms", package: "swift-async-algorithms"),
.product(name: "MarkdownUI", package: "swift-markdown-ui"),
.product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
@@ -238,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"),
]
),
@@ -249,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"),
@@ -273,7 +303,17 @@ let package = Package(
.product(name: "Highlightr", package: "Highlightr"),
]
),
-
+
+ // MARK: Persist Middleware
+ .target(
+ name: "PersistMiddleware",
+ dependencies: [
+ .product(name: "Persist", package: "Tool"),
+ .product(name: "ChatTab", package: "Tool"),
+ .product(name: "ChatAPIService", package: "Tool"),
+ .product(name: "ConversationServiceProvider", package: "Tool")
+ ]
+ )
]
)
diff --git a/Core/Sources/ChatService/ChatInjector.swift b/Core/Sources/ChatService/ChatInjector.swift
index 81a60243..df3d454a 100644
--- a/Core/Sources/ChatService/ChatInjector.swift
+++ b/Core/Sources/ChatService/ChatInjector.swift
@@ -4,7 +4,7 @@ import XcodeInspector
import AXHelper
import ApplicationServices
import AppActivator
-
+import LanguageServerProtocol
public struct ChatInjector {
public init() {}
@@ -22,16 +22,15 @@ public struct ChatInjector {
var lines = editorContent.content.splitByNewLine(
omittingEmptySubsequences: false
).map { String($0) }
- // Ensure the line number is within the bounds of the file
+
guard cursorPosition.line <= lines.count else { return }
var modifications: [Modification] = []
- // remove selection
- // make sure there is selection exist and valid
+ // Handle selection deletion
if let selection = editorContent.selections.first,
- selection.isValid,
- selection.start.line < lines.endIndex {
+ selection.isValid,
+ selection.start.line < lines.endIndex {
let selectionEndLine = min(selection.end.line, lines.count - 1)
let deletedSelection = CursorRange(
start: selection.start,
@@ -39,59 +38,110 @@ public struct ChatInjector {
)
modifications.append(.deletedSelection(deletedSelection))
lines = lines.applying([.deletedSelection(deletedSelection)])
-
- // update cursorPosition to the start of selection
cursorPosition = selection.start
}
- let targetLine = lines[cursorPosition.line]
+ let insertionRange = CursorRange(
+ start: cursorPosition,
+ end: cursorPosition
+ )
- // Determine the indention level of the target line
- let leadingWhitespace = cursorPosition.character > 0 ? targetLine.prefix { $0.isWhitespace } : ""
- let indentation = String(leadingWhitespace)
+ try Self.performInsertion(
+ content: codeBlock,
+ range: insertionRange,
+ lines: &lines,
+ modifications: &modifications,
+ focusElement: focusElement
+ )
- // Insert codeblock at the specified position
- let index = targetLine.index(targetLine.startIndex, offsetBy: min(cursorPosition.character, targetLine.count))
- let before = targetLine[.. String in
- return index == 0 ? String(element) : indentation + String(element)
- }
-
- var toBeInsertedLines = [String]()
- toBeInsertedLines.append(String(before) + codeBlockLines.first!)
- toBeInsertedLines.append(contentsOf: codeBlockLines.dropFirst().dropLast())
- toBeInsertedLines.append(codeBlockLines.last! + String(after))
+ guard range.start.line >= 0,
+ range.start.line < lines.count,
+ range.end.line >= 0,
+ range.end.line < lines.count
+ else { return }
- lines.replaceSubrange((cursorPosition.line)...(cursorPosition.line), with: toBeInsertedLines)
+ var lines = lines
+ var modifications: [Modification] = []
- // Join the lines
- let newContent = String(lines.joined(separator: "\n"))
+ if range.isValid {
+ modifications.append(.deletedSelection(range))
+ lines = lines.applying([.deletedSelection(range)])
+ }
- // Inject updated content
- let newCursorPosition = CursorPosition(
- line: cursorPosition.line + codeBlockLines.count - 1,
- character: codeBlockLines.last?.count ?? 0
- )
- modifications.append(.inserted(cursorPosition.line, toBeInsertedLines))
- try AXHelper().injectUpdatedCodeWithAccessibilityAPI(
- .init(
- content: newContent,
- newSelection: .cursor(newCursorPosition),
- modifications: modifications
- ),
- focusElement: focusElement,
- onSuccess: {
- NSWorkspace.activatePreviousActiveXcode()
- }
-
+ try performInsertion(
+ content: suggestion,
+ range: range,
+ lines: &lines,
+ modifications: &modifications,
+ focusElement: focusElement
)
} catch {
- print("Failed to insert code block: \(error)")
+ print("Failed to insert suggestion: \(error)")
+ }
+ }
+
+ private static func performInsertion(
+ content: String,
+ range: CursorRange,
+ lines: inout [String],
+ modifications: inout [Modification],
+ focusElement: AXUIElement
+ ) throws {
+ let targetLine = lines[range.start.line]
+ let leadingWhitespace = range.start.character > 0 ? targetLine.prefix { $0.isWhitespace } : ""
+ let indentation = String(leadingWhitespace)
+
+ let index = targetLine.index(targetLine.startIndex, offsetBy: min(range.start.character, targetLine.count))
+ let before = targetLine[.. String in
+ return index == 0 ? String(element) : indentation + String(element)
+ }
+
+ var toBeInsertedLines = [String]()
+ if contentLines.count > 1 {
+ toBeInsertedLines.append(String(before) + contentLines.first!)
+ toBeInsertedLines.append(contentsOf: contentLines.dropFirst().dropLast())
+ toBeInsertedLines.append(contentLines.last! + String(after))
+ } else {
+ toBeInsertedLines.append(String(before) + contentLines.first! + String(after))
}
+
+ lines.replaceSubrange((range.start.line)...(range.start.line), with: toBeInsertedLines)
+
+ let newContent = String(lines.joined(separator: "\n"))
+ let newCursorPosition = CursorPosition(
+ line: range.start.line + contentLines.count - 1,
+ character: contentLines.last?.count ?? 0
+ )
+
+ modifications.append(.inserted(range.start.line, toBeInsertedLines))
+
+ try AXHelper().injectUpdatedCodeWithAccessibilityAPI(
+ .init(
+ content: newContent,
+ newSelection: .cursor(newCursorPosition),
+ modifications: modifications
+ ),
+ focusElement: focusElement,
+ onSuccess: {
+ NSWorkspace.activatePreviousActiveXcode()
+ }
+ )
}
}
diff --git a/Core/Sources/ChatService/ChatService.swift b/Core/Sources/ChatService/ChatService.swift
index 99ea2720..e90faa3c 100644
--- a/Core/Sources/ChatService/ChatService.swift
+++ b/Core/Sources/ChatService/ChatService.swift
@@ -7,41 +7,127 @@ import ConversationServiceProvider
import BuiltinExtension
import JSONRPC
import Status
+import Persist
+import PersistMiddleware
+import ChatTab
+import Logger
+import Workspace
+import XcodeInspector
+import OrderedCollections
+import SystemUtils
+import GitHelper
+import LanguageServerProtocol
+import SuggestionBasic
public protocol ChatServiceType {
var memory: ContextAwareAutoManagedChatMemory { get set }
- func send(_ id: String, content: String, skillSet: [ConversationSkill], references: [FileReference]) async throws
+ func send(
+ _ id: String,
+ content: String,
+ contentImages: [ChatCompletionContentPartImage],
+ contentImageReferences: [ImageReference],
+ skillSet: [ConversationSkill],
+ references: [ConversationAttachedReference],
+ model: String?,
+ modelProviderName: String?,
+ reasoningEffort: String?,
+ agentMode: Bool,
+ customChatModeId: String?,
+ userLanguage: String?,
+ turnId: String?
+ ) async throws
func stopReceivingMessage() async
func upvote(_ id: String, _ rating: ConversationRating) async
func downvote(_ id: String, _ rating: ConversationRating) async
func copyCode(_ id: String) async
}
+struct ToolCallRequest {
+ let requestId: JSONId
+ let turnId: String
+ let roundId: Int
+ let toolCallId: String
+ let completion: (AnyJSONRPCResponse) -> Void
+}
+
+struct ConversationTurnTrackingState {
+ var turnParentMap: [String: String] = [:] // Maps subturn ID to parent turn ID
+ var validConversationIds: Set = [] // Tracks all valid conversation IDs including subagents
+
+ mutating func reset() {
+ turnParentMap.removeAll()
+ validConversationIds.removeAll()
+ }
+}
+
public final class ChatService: ChatServiceType, ObservableObject {
public var memory: ContextAwareAutoManagedChatMemory
@Published public internal(set) var chatHistory: [ChatMessage] = []
@Published public internal(set) var isReceivingMessage = false
- public var chatTemplates: [ChatTemplate]? = nil
- public static var shared: ChatService = ChatService.service()
-
+ @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
private var cancellables = Set()
private var activeRequestId: String?
- private var conversationId: String?
+ private(set) public var conversationId: String?
private var skillSet: [ConversationSkill] = []
+ 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) {
+ 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
subscribeToNotifications()
subscribeToConversationContextRequest()
+ subscribeToClientToolInvokeEvent()
+ subscribeToClientToolConfirmationEvent()
+ }
+
+ deinit {
+ Task { [weak self] in
+ await self?.stopReceivingMessage()
+ }
+
+ // Clear all subscriptions
+ cancellables.forEach { $0.cancel() }
+ cancellables.removeAll()
+
+ // Memory will be deallocated automatically
+ }
+
+ public func updateChatTabInfo(_ tabInfo: ChatTabInfo) {
+ // Only isSelected need to be updated
+ chatTabInfo.isSelected = tabInfo.isSelected
}
private func subscribeToNotifications() {
@@ -63,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() {
@@ -75,32 +174,382 @@ public final class ChatService: ChatServiceType, ObservableObject {
}
}).store(in: &cancellables)
}
- public static func service() -> ChatService {
+
+ private func subscribeToClientToolConfirmationEvent() {
+ ClientToolHandlerImpl.shared.onClientToolConfirmationEvent.sink(receiveValue: { [weak self] (request, completion) in
+ 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 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([
+ JSONValue.null,
+ JSONValue.hash(
+ [
+ "code": .number(-32601),
+ "message": .string("Tool function not found")
+ ])
+ ])
+ )
+ )
+ return
+ }
+
+ _ = copilotTool.invokeTool(request, completion: completion, contextProvider: self)
+ }).store(in: &cancellables)
+ }
+
+ 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,
+ parentTurnId: parentTurnId,
+ fileEdits: fileEdits,
+ turnStatus: turnStatus
+ )
+
+ await self.memory.appendMessage(message)
+ }
+ }
+
+ public func notifyChangeTextDocument(fileURL: URL, content: String, version: Int) async throws {
+ try await conversationProvider?.notifyChangeTextDocument(fileURL: fileURL, content: content, version: version, workspaceURL: getWorkspaceURL())
+ }
+
+ public static func service(for chatTabInfo: ChatTabInfo) -> ChatService {
let provider = BuiltinExtensionConversationServiceProvider(
extension: GitHubCopilotExtension.self
)
- return ChatService(provider: provider)
+ return ChatService(provider: provider, chatTabInfo: chatTabInfo)
+ }
+
+ // this will be triggerred in conversation tab if needed
+ public func restoreIfNeeded() {
+ guard self.isRestored == false else { return }
+
+ Task {
+ 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) {
+ // 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)
+ sendToolConfirmationResponse(toolCallRequest, accepted: status == .accepted)
+ }
+
+ // Step 2: Update the tool call status in chat history UI
+ Task {
+ guard let targetMessage = await ToolCallStatusUpdater.findMessageContainingToolCall(
+ toolCallRequest,
+ conversationTurnTracking: conversationTurnTracking,
+ history: await memory.history
+ ) else {
+ return
+ }
+
+ // Search for the tool call in main rounds or subagent rounds
+ if let updatedRound = ToolCallStatusUpdater.findAndUpdateToolCall(
+ toolCallId: toolCallId,
+ newStatus: status,
+ in: targetMessage.editAgentRounds
+ ) {
+ let message = ToolCallStatusUpdater.createMessageUpdate(
+ targetMessage: targetMessage,
+ updatedRound: updatedRound
+ )
+ await memory.appendMessage(message)
+ }
+ }
+ }
+
+ // MARK: - Helper Methods for Tool Call Status Updates
+
+ /// Returns true if the `conversationId` belongs to the active conversation or any subagent conversations.
+ func isConversationIdValid(_ conversationId: String) -> Bool {
+ conversationTurnTracking.validConversationIds.contains(conversationId)
+ }
+
+ /// Workaround: toolConfirmation request does not have parent turnId.
+ func parentTurnIdForTurnId(_ turnId: String) -> String? {
+ conversationTurnTracking.turnParentMap[turnId]
+ }
+
+ func storePendingToolCallRequest(toolCallId: String, request: ToolCallRequest) {
+ pendingToolCallRequests[toolCallId] = request
+ }
+
+ /// Sends the confirmation response (accept/dismiss) back to the server
+ 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 func send(_ id: String, content: String, skillSet: Array, references: Array) async throws {
+ public enum ChatServiceError: Error, LocalizedError {
+ case conflictingImageFormats(String)
+
+ public var errorDescription: String? {
+ switch self {
+ case .conflictingImageFormats(let message):
+ return message
+ }
+ }
+ }
+
+ public func send(
+ _ id: String,
+ content: String,
+ contentImages: Array = [],
+ contentImageReferences: Array = [],
+ skillSet: Array,
+ 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 {
guard activeRequestId == nil else { return }
let workDoneToken = UUID().uuidString
activeRequestId = workDoneToken
- await memory.appendMessage(ChatMessage(id: id, role: .user, content: content, references: []))
- let skillCapabilities: [String] = [ CurrentEditorSkill.ID, ProblemsInActiveDocumentSkill.ID ]
+ let finalImageReferences: [ImageReference]
+ let finalContentImages: [ChatCompletionContentPartImage]
+
+ if !contentImageReferences.isEmpty {
+ // User attached images are all parsed as ImageReference
+ finalImageReferences = contentImageReferences
+ finalContentImages = contentImageReferences
+ .map {
+ ChatCompletionContentPartImage(
+ url: $0.dataURL(imageType: $0.source == .screenshot ? "png" : "")
+ )
+ }
+ } else {
+ // In current implementation, only resend message will have contentImageReferences
+ // No need to convert ChatCompletionContentPartImage to ImageReference for persistence
+ finalImageReferences = []
+ finalContentImages = contentImages
+ }
+
+ var chatMessage = ChatMessage(
+ userMessageWithId: id,
+ chatTabId: chatTabInfo.id,
+ content: content,
+ contentImageReferences: finalImageReferences,
+ references: references.toConversationReferences()
+ )
+
+ let currentEditorSkill = skillSet.first(where: { $0.id == CurrentEditorSkill.ID }) as? CurrentEditorSkill
+ let currentFileReadability = currentEditorSkill == nil
+ ? nil
+ : FileUtils.checkFileReadability(at: currentEditorSkill!.currentFilePath)
+ var errorMessage: ChatMessage?
+
+ var currentTurnId: String? = turnId
+ // If turnId is provided, it is used to update the existing message, no need to append the user message
+ if turnId == nil {
+ if let currentFileReadability, !currentFileReadability.isReadable {
+ // For associating error message with user message
+ currentTurnId = UUID().uuidString
+ chatMessage.clsTurnID = currentTurnId
+ errorMessage = ChatMessage(
+ errorMessageWithId: currentTurnId!,
+ chatTabID: chatTabInfo.id,
+ errorMessages: [
+ currentFileReadability.errorMessage(
+ using: CurrentEditorSkill.readabilityErrorMessageProvider
+ )
+ ].compactMap { $0 }.filter { !$0.isEmpty }
+ )
+ }
+ await memory.appendMessage(chatMessage)
+ }
+
+ // reset file edits
+ self.resetFileEdits()
+
+ // persist
+ saveChatMessageToStorage(chatMessage)
+
+ if content.hasPrefix("/releaseNotes") {
+ if let fileURL = Bundle.main.url(forResource: "ReleaseNotes", withExtension: "md"),
+ let whatsNewContent = try? String(contentsOf: fileURL)
+ {
+ // will be persist in resetOngoingRequest()
+ // there is no turn id from CLS, just set it as id
+ let clsTurnID = UUID().uuidString
+ let progressMessage = ChatMessage(
+ assistantMessageWithId: clsTurnID,
+ chatTabID: chatTabInfo.id,
+ content: whatsNewContent
+ )
+ await memory.appendMessage(progressMessage)
+ }
+ resetOngoingRequest()
+ return
+ }
+
+ if let errorMessage {
+ Task { await memory.appendMessage(errorMessage) }
+ }
+
+ var activeDoc: Doc?
+ var validSkillSet: [ConversationSkill] = skillSet
+ if let currentEditorSkill, currentFileReadability?.isReadable == true {
+ activeDoc = Doc(uri: currentEditorSkill.currentFile.url.absoluteString)
+ } else {
+ validSkillSet.removeAll(where: { $0.id == CurrentEditorSkill.ID || $0.id == ProblemsInActiveDocumentSkill.ID })
+ }
+
+ let request = createConversationRequest(
+ workDoneToken: workDoneToken,
+ content: content,
+ contentImages: finalContentImages,
+ activeDoc: activeDoc,
+ references: references,
+ model: model,
+ modelProviderName: modelProviderName,
+ reasoningEffort: reasoningEffort,
+ agentMode: agentMode,
+ customChatModeId: customChatModeId,
+ userLanguage: userLanguage,
+ turnId: currentTurnId,
+ skillSet: validSkillSet
+ )
+
+ self.lastUserRequest = request
+ self.skillSet = validSkillSet
+
+ 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(
+ workDoneToken: String,
+ content: String,
+ contentImages: [ChatCompletionContentPartImage] = [],
+ activeDoc: Doc?,
+ 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]
+ ) -> ConversationRequest {
+ let skillCapabilities: [String] = [CurrentEditorSkill.ID, ProblemsInActiveDocumentSkill.ID]
let supportedSkills: [String] = skillSet.map { $0.id }
let ignoredSkills: [String] = skillCapabilities.filter {
!supportedSkills.contains($0)
}
- let request = ConversationRequest(workDoneToken: workDoneToken,
- content: content,
- workspaceFolder: "",
- skills: skillCapabilities,
- ignoredSkills: ignoredSkills,
- references: references)
- self.skillSet = skillSet
- try await send(request)
+
+ /// replace the `@workspace` to `@project`
+ let newContent = replaceFirstWord(in: content, from: "@workspace", to: "@project")
+
+ return ConversationRequest(
+ workDoneToken: workDoneToken,
+ content: newContent,
+ contentImages: contentImages,
+ workspaceFolder: "",
+ activeDoc: activeDoc,
+ skills: skillCapabilities,
+ ignoredSkills: ignoredSkills,
+ 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 {
@@ -114,38 +563,62 @@ public final class ChatService: ChatServiceType, ObservableObject {
public func stopReceivingMessage() async {
if let activeRequestId = activeRequestId {
do {
- try await conversationProvider?.stopReceivingMessage(activeRequestId)
+ try await conversationProvider?.stopReceivingMessage(activeRequestId, workspaceURL: getWorkspaceURL())
} catch {
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 }
+
await memory.clearHistory()
if let activeRequestId = activeRequestId {
do {
- try await conversationProvider?.stopReceivingMessage(activeRequestId)
+ try await conversationProvider?.stopReceivingMessage(activeRequestId, workspaceURL: getWorkspaceURL())
} catch {
print("Failed to cancel ongoing request with WDT: \(activeRequestId)")
}
}
+
+ deleteAllChatMessagesFromStorage(messageIds)
resetOngoingRequest()
}
-
- public func deleteMessage(id: String) async {
- await memory.removeMessage(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) async throws {
- if let message = (await memory.history).first(where: { $0.id == id })
+ public func resendMessage(id: String, model: String? = nil, modelProviderName: String? = nil) async throws {
+ if let _ = (await memory.history).first(where: { $0.id == id }),
+ let lastUserRequest
{
- do {
- try await send(id, content: message.content, skillSet: [], references: [])
- } catch {
- print("Failed to resend message")
- }
+ // TODO: clean up contents for resend message
+ activeRequestId = nil
+ try await send(
+ id,
+ content: lastUserRequest.content,
+ contentImages: lastUserRequest.contentImages,
+ skillSet: skillSet,
+ references: lastUserRequest.references ?? [],
+ model: model != nil ? model : lastUserRequest.model,
+ modelProviderName: modelProviderName,
+ agentMode: lastUserRequest.agentMode,
+ customChatModeId: lastUserRequest.customChatModeId,
+ userLanguage: lastUserRequest.userLanguage,
+ turnId: id
+ )
}
}
@@ -153,10 +626,14 @@ public final class ChatService: ChatServiceType, ObservableObject {
if let message = (await memory.history).first(where: { $0.id == id })
{
await mutateHistory { history in
- history.append(.init(
+ let chatMessage: ChatMessage = .init(
+ chatTabID: self.chatTabInfo.id,
role: .assistant,
content: message.content
- ))
+ )
+
+ history.append(chatMessage)
+ self.saveChatMessageToStorage(chatMessage)
}
}
}
@@ -200,10 +677,13 @@ public final class ChatService: ChatServiceType, ObservableObject {
if info.specifiedSystemPrompt != nil || info.extraSystemPrompt != nil {
await mutateHistory { history in
- history.append(.init(
+ let chatMessage: ChatMessage = .init(
+ chatTabID: self.chatTabInfo.id,
role: .assistant,
content: ""
- ))
+ )
+ history.append(chatMessage)
+ self.saveChatMessageToStorage(chatMessage)
}
}
@@ -213,34 +693,35 @@ public final class ChatService: ChatServiceType, ObservableObject {
try await send(UUID().uuidString, content: templateProcessor.process(sendingMessageImmediately), skillSet: [], references: [])
}
}
+
+ public func getWorkspaceURL() -> URL? {
+ guard !chatTabInfo.workspacePath.isEmpty else {
+ return nil
+ }
+ return URL(fileURLWithPath: chatTabInfo.workspacePath)
+ }
+
+ public func getProjectRootURL() -> URL? {
+ guard let workspaceURL = getWorkspaceURL() else { return nil }
+ return WorkspaceXcodeWindowInspector.extractProjectURL(
+ workspaceURL: workspaceURL,
+ documentURL: nil
+ )
+ }
public func upvote(_ id: String, _ rating: ConversationRating) async {
- try? await conversationProvider?.rateConversation(turnId: id, rating: rating)
+ try? await conversationProvider?.rateConversation(turnId: id, rating: rating, workspaceURL: getWorkspaceURL())
}
public func downvote(_ id: String, _ rating: ConversationRating) async {
- try? await conversationProvider?.rateConversation(turnId: id, rating: rating)
+ try? await conversationProvider?.rateConversation(turnId: id, rating: rating, workspaceURL: getWorkspaceURL())
}
public func copyCode(_ id: String) async {
// TODO: pass copy code info to Copilot server
}
- public func loadChatTemplates() async -> [ChatTemplate]? {
- guard self.chatTemplates == nil else { return self.chatTemplates }
-
- do {
- if let templates = (try await conversationProvider?.templates()) {
- self.chatTemplates = templates
- return templates
- }
- } catch {
- // handle error if desired
- }
-
- return nil
- }
-
+ // not used
public func handleSingleRoundDialogCommand(
systemPrompt: String?,
overwriteSystemPrompt: Bool,
@@ -252,11 +733,55 @@ 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 }) {
- lastUserMessage.turnId = progress.turnId
+
+ // Case: New conversation where error message was generated before CLS request
+ // Using clsTurnId to associate this error message with the corresponding user message
+ // When merging error messages with bot responses from CLS, these properties need to be updated
+ await memory.mutateHistory { history in
+ if let existingBotIndex = history.lastIndex(where: {
+ $0.role == .assistant && $0.clsTurnID == lastUserMessage.clsTurnID
+ }) {
+ history[existingBotIndex].id = turnId
+ history[existingBotIndex].clsTurnID = turnId
+ }
+ }
+
+ lastUserMessage.clsTurnID = progress.turnId
+ saveChatMessageToStorage(lastUserMessage)
+ }
+
+ /// 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.
+ /// 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)
}
}
}
@@ -265,100 +790,869 @@ 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 {
- progressReferences.forEach { item in
- let reference = ConversationReference(
- uri: item.uri,
- status: .included,
- kind: .other
- )
- references.append(reference)
- }
+ references = progressReferences.toConversationReferences()
}
-
- if content.isEmpty && references.isEmpty {
+
+ if let progressSteps = progress.steps, !progressSteps.isEmpty {
+ steps = progressSteps
+ }
+
+ if let progressAgentRounds = progress.editAgentRounds, !progressAgentRounds.isEmpty {
+ editAgentRounds = progressAgentRounds
+ }
+
+ 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
+ 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(id: id, role: .assistant, content: messageContent, references: messageReferences)
+ let message = ChatMessage(
+ assistantMessageWithId: id,
+ chatTabID: chatTabInfo.id,
+ content: messageContent,
+ references: messageReferences,
+ steps: messageSteps,
+ editAgentRounds: messageAgentRounds,
+ thinking: messageThinking,
+ parentTurnId: messageParentTurnId,
+ turnStatus: .inProgress
+ )
+
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?.createTurn(with: conversationId, request: request)
+ return try await conversationProvider?
+ .createTurn(
+ with: conversationId,
+ request: request,
+ workspaceURL: getWorkspaceURL()
+ )
} else {
- try await conversationProvider?.createConversation(request)
+ var requestWithTurns = request
+
+ var chatHistory = self.chatHistory
+ // remove the last user message
+ let _ = chatHistory.popLast()
+ if chatHistory.count > 0 {
+ // invoke history turns
+ let turns = chatHistory.toTurns()
+ requestWithTurns.turns = turns
+ }
+
+ return try await conversationProvider?.createConversation(requestWithTurns, workspaceURL: getWorkspaceURL())
}
} catch {
- resetOngoingRequest()
+ resetOngoingRequest(with: .error)
throw error
}
}
+
+ private func deleteTurns(_ turnIds: [String]) async {
+ guard !turnIds.isEmpty, let conversationId = conversationId else {
+ return
+ }
+
+ 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)")
+ }
+ }
+ }
+
+ // MARK: - Certificate Error Detection
+
+ /// Checks if an error is related to SSL certificate issues
+ private func isCertificateError(_ error: Error) -> Bool {
+ let errorDescription = error.localizedDescription.lowercased()
+
+ // Check for certificate error messages
+ if errorDescription.contains("unable to get local issuer certificate") ||
+ errorDescription.contains("self-signed certificate in certificate chain") ||
+ errorDescription.contains("unable_to_get_issuer_cert_locally") {
+ return true
+ }
+
+ // Check GitHubCopilotError with ServerError
+ if let serverError = error as? ServerError,
+ case .serverError(_, let message, _) = serverError {
+ let serverMessage = message.lowercased()
+ if serverMessage.contains("unable to get local issuer certificate") ||
+ serverMessage.contains("self-signed certificate in certificate chain") {
+ return true
+ }
+ }
+
+ return false
+ }
+
+ private func showCertificateErrorMessage(turnId: String?) async {
+ let messageId = turnId ?? UUID().uuidString
+ let errorMessage = ChatMessage(
+ errorMessageWithId: messageId,
+ chatTabID: chatTabInfo.id,
+ errorMessages: [
+ SSLCertificateErrorMessage
+ ]
+ )
+ await memory.appendMessage(errorMessage)
+ }
}
+
+public final class SharedChatService {
+ public var chatTemplates: [ChatTemplate]? = nil
+ public var chatAgents: [ChatAgent]? = nil
+ public var conversationModes: [ConversationMode]? = nil
+ private let conversationProvider: ConversationServiceProvider?
+
+ public static let shared = SharedChatService.service()
+
+ init(provider: any ConversationServiceProvider) {
+ self.conversationProvider = provider
+ }
+
+ public static func service() -> SharedChatService {
+ let provider = BuiltinExtensionConversationServiceProvider(
+ extension: GitHubCopilotExtension.self
+ )
+ return SharedChatService(provider: provider)
+ }
+
+ public func loadChatTemplates() async -> [ChatTemplate]? {
+ do {
+ if let templates = (try await conversationProvider?.templates()) {
+ self.chatTemplates = templates
+ return templates
+ }
+ } catch {
+ // handle error if desired
+ }
+
+ 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
+ }
+
+ public func loadChatAgents() async -> [ChatAgent]? {
+ guard self.chatAgents == nil else { return self.chatAgents }
+
+ do {
+ if let chatAgents = (try await conversationProvider?.agents()) {
+ self.chatAgents = chatAgents
+ return chatAgents
+ }
+ } catch {
+ // handle error if desired
+ }
+
+ return nil
+ }
+}
+
+
+extension ChatService {
+
+ // do storage operatoin in the background
+ private func runInBackground(_ operation: @escaping () -> Void) {
+ Task.detached(priority: .utility) {
+ operation()
+ }
+ }
+
+ func saveChatMessageToStorage(_ message: ChatMessage) {
+ runInBackground {
+ ChatMessageStore.save(message, with: .init(workspacePath: self.chatTabInfo.workspacePath, username: self.chatTabInfo.username))
+ }
+ }
+
+ func deleteChatMessageFromStorage(_ id: String) {
+ runInBackground {
+ ChatMessageStore.delete(by: id, with: .init(workspacePath: self.chatTabInfo.workspacePath, username: self.chatTabInfo.username))
+ }
+ }
+ func deleteAllChatMessagesFromStorage(_ ids: [String]) {
+ runInBackground {
+ ChatMessageStore.deleteAll(by: ids, with: .init(workspacePath: self.chatTabInfo.workspacePath, username: self.chatTabInfo.username))
+ }
+ }
+
+ func fetchAllChatMessagesFromStorage() -> [ChatMessage] {
+ return ChatMessageStore.getAll(by: self.chatTabInfo.id, metadata: .init(workspacePath: self.chatTabInfo.workspacePath, username: self.chatTabInfo.username))
+ }
+}
+
+func replaceFirstWord(in content: String, from oldWord: String, to newWord: String) -> String {
+ let pattern = "^\(oldWord)\\b"
+
+ if let regex = try? NSRegularExpression(pattern: pattern, options: []) {
+ let range = NSRange(location: 0, length: content.utf16.count)
+ return regex.stringByReplacingMatches(in: content, options: [], range: range, withTemplate: newWord)
+ }
+
+ return content
+}
+
+extension Array where Element == FileReference {
+ func toConversationReferences() -> [ConversationReference] {
+ return self.map {
+ .init(uri: $0.uri, status: .included, kind: .reference($0), referenceType: .file)
+ }
+ }
+}
+
+extension Array where Element == ConversationAttachedReference {
+ func toConversationReferences() -> [ConversationReference] {
+ return self.map {
+ switch $0 {
+ case .file(let fileRef):
+ .init(
+ uri: fileRef.url.path,
+ status: .included,
+ kind: .fileReference($0),
+ referenceType: .file)
+ case .directory(let directoryRef):
+ .init(
+ uri: directoryRef.url.path,
+ status: .included,
+ kind: .fileReference($0),
+ referenceType: .directory)
+ }
+ }
+ }
+}
+
+extension [ChatMessage] {
+ // transfer chat messages to turns
+ // used to restore chat history for CLS
+ func toTurns() -> [TurnSchema] {
+ var turns: [TurnSchema] = []
+ let count = self.count
+ var index = 0
+
+ while index < count {
+ let message = self[index]
+ if case .user = message.role {
+ var turn = TurnSchema(request: message.content, turnId: message.clsTurnID)
+ // has next message
+ if index + 1 < count {
+ let nextMessage = self[index + 1]
+ if nextMessage.role == .assistant {
+ turn.response = nextMessage.content + extractContentFromEditAgentRounds(nextMessage.editAgentRounds)
+ index += 1
+ }
+ }
+ turns.append(turn)
+ }
+ index += 1
+ }
+
+ return turns
+ }
+
+ private func extractContentFromEditAgentRounds(_ editAgentRounds: [AgentRound]) -> String {
+ var content = ""
+ for round in editAgentRounds {
+ if !round.reply.isEmpty {
+ content += round.reply
+ }
+ }
+ return content
+ }
+}
+
+// MARK: Copilot Code Review
+
+extension ChatService {
+
+ public func requestCodeReview(_ group: GitDiffGroup) async throws {
+ guard activeRequestId == nil else { return }
+ activeRequestId = UUID().uuidString
+
+ guard !isReceivingMessage else {
+ activeRequestId = nil
+ throw CancellationError()
+ }
+ isReceivingMessage = true
+ requestType = .codeReview
+ let turnId = UUID().uuidString
+
+ await CodeReviewService.shared.resetComments()
+
+ await addCodeReviewUserMessage(id: UUID().uuidString, turnId: turnId, group: group)
+
+ let initialBotMessage = ChatMessage(
+ assistantMessageWithId: turnId,
+ chatTabID: chatTabInfo.id,
+ turnStatus: .inProgress,
+ requestType: .codeReview
+ )
+ await memory.appendMessage(initialBotMessage)
+
+ guard let projectRootURL = getProjectRootURL()
+ else {
+ let round = CodeReviewRound.fromError(turnId: turnId, error: "Invalid git repository.")
+ await appendCodeReviewRound(round)
+ resetOngoingRequest(with: .error)
+ return
+ }
+
+ let prChanges = await CurrentChangeService.getPRChanges(
+ projectRootURL,
+ group: group,
+ shouldIncludeFile: shouldIncludeFileForReview
+ )
+ guard !prChanges.isEmpty else {
+ let round = CodeReviewRound.fromError(
+ turnId: turnId,
+ error: group == .index
+ ? "No staged changes found to review."
+ : "No unstaged changes found to review."
+ )
+ await appendCodeReviewRound(round)
+ resetOngoingRequest()
+ return
+ }
+
+ let round: CodeReviewRound = .init(
+ turnId: turnId,
+ status: .waitForConfirmation,
+ request: .from(prChanges)
+ )
+ await appendCodeReviewRound(round, turnStatus: .waitForConfirmation)
+ }
+
+ private func shouldIncludeFileForReview(url: URL) -> Bool {
+ let codeLanguage = CodeLanguage(fileURL: url)
+
+ if case .builtIn = codeLanguage {
+ return true
+ } else {
+ return false
+ }
+ }
+
+ private func appendCodeReviewRound(
+ _ round: CodeReviewRound,
+ turnStatus: ChatMessage.TurnStatus? = nil
+ ) async {
+ let message = ChatMessage(
+ assistantMessageWithId: round.turnId,
+ chatTabID: chatTabInfo.id,
+ codeReviewRound: round,
+ turnStatus: turnStatus
+ )
+
+ await memory.appendMessage(message)
+ }
+
+ private func getCurrentCodeReviewRound(_ id: String) async -> CodeReviewRound? {
+ guard let lastBotMessage = await memory.history.last,
+ lastBotMessage.role == .assistant,
+ let codeReviewRound = lastBotMessage.codeReviewRound,
+ codeReviewRound.id == id
+ else {
+ return nil
+ }
+
+ return codeReviewRound
+ }
+
+ public func acceptCodeReview(_ id: String, selectedFileUris: [DocumentUri]) async {
+ guard activeRequestId != nil, isReceivingMessage else { return }
+
+ guard var round = await getCurrentCodeReviewRound(id),
+ var request = round.request,
+ round.status.canTransitionTo(.accepted)
+ else { return }
+
+ guard selectedFileUris.count > 0 else {
+ round = round.withError("No files are selected to review.")
+ await appendCodeReviewRound(round)
+ resetOngoingRequest()
+ return
+ }
+
+ round.status = .accepted
+ request.updateSelectedChanges(by: selectedFileUris)
+ round.request = request
+ await appendCodeReviewRound(round, turnStatus: .inProgress)
+
+ round.status = .running
+ await appendCodeReviewRound(round)
+
+ let (fileComments, errorMessage) = await CodeReviewProvider.invoke(
+ request,
+ context: CodeReviewServiceProvider(conversationServiceProvider: conversationProvider)
+ )
+
+ if let errorMessage = errorMessage {
+ round = round.withError(errorMessage)
+ await appendCodeReviewRound(round)
+ resetOngoingRequest(with: .error)
+ return
+ }
+
+ round = round.withResponse(.init(fileComments: fileComments))
+ await CodeReviewService.shared.updateComments(fileComments)
+ await appendCodeReviewRound(round)
+
+ round.status = .completed
+ await appendCodeReviewRound(round)
+
+ resetOngoingRequest()
+ }
+
+ public func cancelCodeReview(_ id: String) async {
+ guard activeRequestId != nil, isReceivingMessage else { return }
+
+ guard var round = await getCurrentCodeReviewRound(id),
+ round.status.canTransitionTo(.cancelled)
+ else { return }
+
+ round.status = .cancelled
+ await appendCodeReviewRound(round)
+
+ 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,
+ requestType: .codeReview
+ )
+ await memory.appendMessage(chatMessage)
+ saveChatMessageToStorage(chatMessage)
+ }
+}
diff --git a/Core/Sources/ChatService/CodeReview/CodeReviewProvider.swift b/Core/Sources/ChatService/CodeReview/CodeReviewProvider.swift
new file mode 100644
index 00000000..c41eb61b
--- /dev/null
+++ b/Core/Sources/ChatService/CodeReview/CodeReviewProvider.swift
@@ -0,0 +1,57 @@
+import ChatAPIService
+import ConversationServiceProvider
+import Foundation
+import Logger
+import GitHelper
+
+public struct CodeReviewServiceProvider {
+ public var conversationServiceProvider: (any ConversationServiceProvider)?
+}
+
+public struct CodeReviewProvider {
+ public static func invoke(
+ _ request: CodeReviewRequest,
+ context: CodeReviewServiceProvider
+ ) async -> (fileComments: [CodeReviewResponse.FileComment], errorMessage: String?) {
+ var fileComments: [CodeReviewResponse.FileComment] = []
+ var errorMessage: String?
+
+ do {
+ if let result = try await requestReviewChanges(request.fileChange.selectedChanges, context: context) {
+ for comment in result.comments {
+ guard let change = request.fileChange.selectedChanges.first(where: { $0.uri == comment.uri }) else {
+ continue
+ }
+
+ if let index = fileComments.firstIndex(where: { $0.uri == comment.uri }) {
+ var currentFileComments = fileComments[index]
+ currentFileComments.comments.append(comment)
+ fileComments[index] = currentFileComments
+
+ } else {
+ fileComments.append(
+ .init(uri: change.uri, originalContent: change.originalContent, comments: [comment])
+ )
+ }
+ }
+ }
+ } catch {
+ Logger.gitHubCopilot.error("Failed to review change: \(error)")
+ errorMessage = "Oops, failed to review changes."
+ }
+
+ return (fileComments, errorMessage)
+ }
+
+ private static func requestReviewChanges(
+ _ changes: [PRChange],
+ context: CodeReviewServiceProvider
+ ) async throws -> CodeReviewResult? {
+ return try await context.conversationServiceProvider?
+ .reviewChanges(
+ changes.map {
+ .init(uri: $0.uri, path: $0.path, baseContent: $0.baseContent, headContent: $0.headContent)
+ }
+ )
+ }
+}
diff --git a/Core/Sources/ChatService/CodeReview/CodeReviewService.swift b/Core/Sources/ChatService/CodeReview/CodeReviewService.swift
new file mode 100644
index 00000000..4ae308d1
--- /dev/null
+++ b/Core/Sources/ChatService/CodeReview/CodeReviewService.swift
@@ -0,0 +1,48 @@
+import Collections
+import ConversationServiceProvider
+import Foundation
+import LanguageServerProtocol
+
+public struct DocumentReview: Equatable {
+ public var comments: [ReviewComment]
+ public let originalContent: String
+}
+
+public typealias DocumentReviewsByUri = OrderedDictionary
+
+@MainActor
+public class CodeReviewService: ObservableObject {
+ @Published public private(set) var documentReviews: DocumentReviewsByUri = [:]
+
+ public static let shared = CodeReviewService()
+
+ private init() {}
+
+ public func updateComments(for uri: DocumentUri, comments: [ReviewComment], originalContent: String) {
+ if var existing = documentReviews[uri] {
+ existing.comments.append(contentsOf: comments)
+ existing.comments = sortedComments(existing.comments)
+ documentReviews[uri] = existing
+ } else {
+ documentReviews[uri] = .init(comments: comments, originalContent: originalContent)
+ }
+ }
+
+ public func updateComments(_ fileComments: [CodeReviewResponse.FileComment]) {
+ for fileComment in fileComments {
+ updateComments(
+ for: fileComment.uri,
+ comments: fileComment.comments,
+ originalContent: fileComment.originalContent
+ )
+ }
+ }
+
+ private func sortedComments(_ comments: [ReviewComment]) -> [ReviewComment] {
+ return comments.sorted { $0.range.end.line < $1.range.end.line }
+ }
+
+ public func resetComments() {
+ documentReviews = [:]
+ }
+}
diff --git a/Core/Sources/ChatService/ContextAwareAutoManagedChatMemory.swift b/Core/Sources/ChatService/ContextAwareAutoManagedChatMemory.swift
index e86ede8b..f185f9b1 100644
--- a/Core/Sources/ChatService/ContextAwareAutoManagedChatMemory.swift
+++ b/Core/Sources/ChatService/ContextAwareAutoManagedChatMemory.swift
@@ -18,6 +18,8 @@ public final class ContextAwareAutoManagedChatMemory: ChatMemory {
systemPrompt: ""
)
}
+
+ deinit { }
public func mutateHistory(_ update: (inout [ChatMessage]) -> Void) async {
await memory.mutateHistory(update)
diff --git a/Core/Sources/ChatService/CurrentEditorSkill.swift b/Core/Sources/ChatService/CurrentEditorSkill.swift
deleted file mode 100644
index 28ab47bc..00000000
--- a/Core/Sources/ChatService/CurrentEditorSkill.swift
+++ /dev/null
@@ -1,33 +0,0 @@
-import ConversationServiceProvider
-import Foundation
-import GitHubCopilotService
-import JSONRPC
-
-public class CurrentEditorSkill: ConversationSkill {
- public static let ID = "current-editor"
- private var currentFile: FileReference
- public var id: String {
- return CurrentEditorSkill.ID
- }
-
- public init(
- currentFile: FileReference
- ) {
- self.currentFile = currentFile
- }
-
- public func applies(params: ConversationContextParams) -> Bool {
- return params.skillId == self.id
- }
-
- public func resolveSkill(request: ConversationContextRequest, completion: (AnyJSONRPCResponse) -> Void){
- let uri: String? = self.currentFile.url.absoluteString
- completion(
- AnyJSONRPCResponse(id: request.id,
- result: JSONValue.array([
- JSONValue.hash(["uri" : .string(uri ?? "")]),
- JSONValue.null
- ]))
- )
- }
-}
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/ConversationSkill.swift b/Core/Sources/ChatService/Skills/ConversationSkill.swift
similarity index 67%
rename from Core/Sources/ChatService/ConversationSkill.swift
rename to Core/Sources/ChatService/Skills/ConversationSkill.swift
index df2735e3..d7883b8e 100644
--- a/Core/Sources/ChatService/ConversationSkill.swift
+++ b/Core/Sources/ChatService/Skills/ConversationSkill.swift
@@ -1,8 +1,10 @@
import JSONRPC
import GitHubCopilotService
+public typealias JSONRPCResponseHandler = (AnyJSONRPCResponse) -> Void
+
public protocol ConversationSkill {
var id: String { get }
func applies(params: ConversationContextParams) -> Bool
- func resolveSkill(request: ConversationContextRequest, completion: @escaping (AnyJSONRPCResponse) -> Void)
+ func resolveSkill(request: ConversationContextRequest, completion: @escaping JSONRPCResponseHandler)
}
diff --git a/Core/Sources/ChatService/Skills/CurrentEditorSkill.swift b/Core/Sources/ChatService/Skills/CurrentEditorSkill.swift
new file mode 100644
index 00000000..19f4aa8d
--- /dev/null
+++ b/Core/Sources/ChatService/Skills/CurrentEditorSkill.swift
@@ -0,0 +1,62 @@
+import ConversationServiceProvider
+import Foundation
+import GitHubCopilotService
+import JSONRPC
+import SystemUtils
+import LanguageServerProtocol
+
+public class CurrentEditorSkill: ConversationSkill {
+ public static let ID = "current-editor"
+ public let currentFile: ConversationFileReference
+ public var id: String {
+ return CurrentEditorSkill.ID
+ }
+ public var currentFilePath: String { currentFile.url.path }
+
+ public init(
+ currentFile: ConversationFileReference
+ ) {
+ self.currentFile = currentFile
+ }
+
+ public func applies(params: ConversationContextParams) -> Bool {
+ return params.skillId == self.id
+ }
+
+ public static let readabilityErrorMessageProvider: FileUtils.ReadabilityErrorMessageProvider = { status in
+ switch status {
+ case .readable:
+ return nil
+ case .notFound:
+ return "Copilot can’t find the current file, so it's not included."
+ case .permissionDenied:
+ return "Copilot can't access the current file. Enable \"Files & Folders\" access in [System Settings](x-apple.systempreferences:com.apple.preference.security?Privacy_FilesAndFolders)."
+ }
+ }
+
+ public func resolveSkill(request: ConversationContextRequest, completion: JSONRPCResponseHandler){
+ let uri: String? = self.currentFile.url.absoluteString
+ let response: JSONValue
+
+ if let fileSelection = currentFile.selection {
+ let start = fileSelection.start
+ let end = fileSelection.end
+ response = .hash([
+ "uri": .string(uri ?? ""),
+ "selection": .hash([
+ "start": .hash(["line": .number(Double(start.line)), "character": .number(Double(start.character))]),
+ "end": .hash(["line": .number(Double(end.line)), "character": .number(Double(end.character))])
+ ])
+ ])
+ } else {
+ // No text selection - only include file URI without selection metadata
+ response = .hash(["uri": .string(uri ?? "")])
+ }
+
+ completion(
+ AnyJSONRPCResponse(
+ id: request.id,
+ result: JSONValue.array([response, JSONValue.null]))
+ )
+ }
+}
diff --git a/Core/Sources/ChatService/ProblemsInActiveDocumentSkill.swift b/Core/Sources/ChatService/Skills/ProblemsInActiveDocumentSkill.swift
similarity index 97%
rename from Core/Sources/ChatService/ProblemsInActiveDocumentSkill.swift
rename to Core/Sources/ChatService/Skills/ProblemsInActiveDocumentSkill.swift
index 22f6d3d8..203872db 100644
--- a/Core/Sources/ChatService/ProblemsInActiveDocumentSkill.swift
+++ b/Core/Sources/ChatService/Skills/ProblemsInActiveDocumentSkill.swift
@@ -17,7 +17,7 @@ public class ProblemsInActiveDocumentSkill: ConversationSkill {
return params.skillId == self.id
}
- public func resolveSkill(request: ConversationContextRequest, completion: @escaping (AnyJSONRPCResponse) -> Void) {
+ public func resolveSkill(request: ConversationContextRequest, completion: @escaping JSONRPCResponseHandler) {
Task {
let editor = await XcodeInspector.shared.getFocusedEditorContent()
let result: JSONValue = JSONValue.hash([
diff --git a/Core/Sources/ChatService/Skills/ProjectContextSkill.swift b/Core/Sources/ChatService/Skills/ProjectContextSkill.swift
new file mode 100644
index 00000000..1575db9b
--- /dev/null
+++ b/Core/Sources/ChatService/Skills/ProjectContextSkill.swift
@@ -0,0 +1,64 @@
+import Foundation
+import Workspace
+import GitHubCopilotService
+import JSONRPC
+import XcodeInspector
+
+/*
+ * project-context is different from others
+ * 1. The CLS only request this skill once `after initialized` instead of during conversation / turn.
+ * 2. After resolved skill, a file watcher needs to be start for syncing file modification to CLS
+ */
+public class ProjectContextSkill {
+ public static let ID = "project-context"
+ public static let ProgressID = "collect-project-context"
+
+ public static var resolvedWorkspace: Set = Set()
+
+ public static func isWorkspaceResolved(_ path: String) -> Bool {
+ return ProjectContextSkill.resolvedWorkspace.contains(path)
+ }
+
+ public init() { }
+
+ /*
+ * The request from CLS only contain the projectPath (a initialization paramter for CLS)
+ * whereas to get files for xcode workspace, the workspacePath is needed.
+ */
+ public static func resolveSkill(
+ request: WatchedFilesRequest,
+ workspacePath: String,
+ completion: JSONRPCResponseHandler
+ ) {
+ guard !ProjectContextSkill.isWorkspaceResolved(workspacePath) else {return }
+
+ let params = request.params!
+
+ guard params.workspaceFolder.uri != "/" else { return }
+
+ /// build workspace URL
+ let workspaceURL = URL(fileURLWithPath: workspacePath)
+ /// refer to `init` in `Workspace`
+ let projectURL = WorkspaceXcodeWindowInspector.extractProjectURL(
+ workspaceURL: workspaceURL,
+ documentURL: nil
+ ) ?? workspaceURL
+
+ /// ignore invalid resolve request
+ guard projectURL.absoluteString == params.workspaceFolder.uri else { return }
+
+ let files = WorkspaceFile.getWatchedFiles(
+ workspaceURL: workspaceURL,
+ projectURL: projectURL,
+ excludeGitIgnoredFiles: params.excludeGitignoredFiles,
+ excludeIDEIgnoredFiles: params.excludeIDEIgnoredFiles
+ )
+
+ let jsonResult = try? JSONEncoder().encode(["files": files])
+ let jsonValue = (try? JSONDecoder().decode(JSONValue.self, from: jsonResult ?? Data())) ?? JSONValue.null
+
+ completion(AnyJSONRPCResponse(id: request.id, result: jsonValue))
+
+ ProjectContextSkill.resolvedWorkspace.insert(workspacePath)
+ }
+}
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/CopilotToolRegistry.swift b/Core/Sources/ChatService/ToolCalls/CopilotToolRegistry.swift
new file mode 100644
index 00000000..f03d2fe5
--- /dev/null
+++ b/Core/Sources/ChatService/ToolCalls/CopilotToolRegistry.swift
@@ -0,0 +1,19 @@
+import ConversationServiceProvider
+
+public class CopilotToolRegistry {
+ public static let shared = CopilotToolRegistry()
+ private var tools: [String: ICopilotTool] = [:]
+
+ private init() {
+ tools[ToolName.runInTerminal.rawValue] = RunInTerminalTool()
+ tools[ToolName.getTerminalOutput.rawValue] = GetTerminalOutputTool()
+ tools[ToolName.getErrors.rawValue] = GetErrorsTool()
+ tools[ToolName.insertEditIntoFile.rawValue] = InsertEditIntoFileTool()
+ tools[ToolName.createFile.rawValue] = CreateFileTool()
+ tools[ToolName.fetchWebPage.rawValue] = FetchWebPageTool()
+ }
+
+ public func getTool(name: String) -> ICopilotTool? {
+ return tools[name]
+ }
+}
diff --git a/Core/Sources/ChatService/ToolCalls/CreateFileTool.swift b/Core/Sources/ChatService/ToolCalls/CreateFileTool.swift
new file mode 100644
index 00000000..702ade22
--- /dev/null
+++ b/Core/Sources/ChatService/ToolCalls/CreateFileTool.swift
@@ -0,0 +1,101 @@
+import JSONRPC
+import AppKit
+import ConversationServiceProvider
+import Foundation
+import Logger
+import ChatAPIService
+
+public class CreateFileTool: ICopilotTool {
+ public static let name = ToolName.createFile
+
+ public func invokeTool(
+ _ request: InvokeClientToolRequest,
+ completion: @escaping (AnyJSONRPCResponse) -> Void,
+ contextProvider: (any ToolContextProvider)?
+ ) -> Bool {
+ guard let params = request.params,
+ let input = params.input,
+ let filePath = input["filePath"]?.value as? String,
+ let content = input["content"]?.value as? String
+ else {
+ completeResponse(request, status: .error, response: "Invalid parameters", completion: completion)
+ return true
+ }
+
+ let fileURL = URL(fileURLWithPath: filePath)
+
+ guard !FileManager.default.fileExists(atPath: filePath)
+ else {
+ Logger.client.info("CreateFileTool: File already exists at \(filePath)")
+ completeResponse(request, status: .error, response: "File already exists at \(filePath)", completion: completion)
+ return true
+ }
+
+ do {
+ // Create intermediate directories if they don't exist
+ let parentDirectory = fileURL.deletingLastPathComponent()
+ try FileManager.default.createDirectory(at: parentDirectory, withIntermediateDirectories: true, attributes: nil)
+ try content.write(to: fileURL, atomically: true, encoding: .utf8)
+ } catch {
+ Logger.client.error("CreateFileTool: Failed to write content to file at \(filePath): \(error)")
+ completeResponse(request, status: .error, response: "Failed to write content to file: \(error)", completion: completion)
+ return true
+ }
+
+ guard FileManager.default.fileExists(atPath: filePath),
+ let writtenContent = try? String(contentsOf: fileURL, encoding: .utf8)
+ else {
+ Logger.client.info("CreateFileTool: Failed to verify file creation at \(filePath)")
+ completeResponse(request, status: .error, response: "Failed to verify file creation.", completion: completion)
+ return true
+ }
+
+ 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 {
+ Logger.client.info("Failed to open file at \(filePath), \(error)")
+ }
+ }
+
+ let editAgentRounds: [AgentRound] = [
+ .init(
+ roundId: params.roundId,
+ reply: "",
+ toolCalls: [
+ .init(
+ id: params.toolCallId,
+ name: params.name,
+ status: .completed,
+ invokeParams: params
+ )
+ ]
+ )
+ ]
+
+ contextProvider?.updateChatHistory(params.turnId, editAgentRounds: editAgentRounds, fileEdits: [fileEdit])
+
+ completeResponse(
+ request,
+ response: "File created at \(filePath).",
+ completion: completion
+ )
+ return true
+ }
+
+ public static func undo(for fileURL: URL) throws {
+ var isDirectory: ObjCBool = false
+ guard FileManager.default.fileExists(atPath: fileURL.path, isDirectory: &isDirectory),
+ !isDirectory.boolValue
+ else { return }
+
+ try FileManager.default.removeItem(at: fileURL)
+ }
+}
diff --git a/Core/Sources/ChatService/ToolCalls/FetchWebPageTool.swift b/Core/Sources/ChatService/ToolCalls/FetchWebPageTool.swift
new file mode 100644
index 00000000..c9f95260
--- /dev/null
+++ b/Core/Sources/ChatService/ToolCalls/FetchWebPageTool.swift
@@ -0,0 +1,45 @@
+import AppKit
+import AXExtension
+import AXHelper
+import ConversationServiceProvider
+import Foundation
+import JSONRPC
+import Logger
+import WebKit
+import WebContentExtractor
+
+public class FetchWebPageTool: ICopilotTool {
+ public static let name = ToolName.fetchWebPage
+
+ public func invokeTool(
+ _ request: InvokeClientToolRequest,
+ completion: @escaping (AnyJSONRPCResponse) -> Void,
+ contextProvider: (any ToolContextProvider)?
+ ) -> Bool {
+ guard let params = request.params,
+ let input = params.input,
+ let urls = input["urls"]?.value as? [String]
+ else {
+ completeResponse(request, status: .error, response: "Invalid parameters", completion: completion)
+ return true
+ }
+
+ guard !urls.isEmpty else {
+ completeResponse(request, status: .error, response: "No valid URLs provided", completion: completion)
+ return true
+ }
+
+ // Use the improved WebContentFetcher to fetch content from all URLs
+ Task {
+ let results = await WebContentFetcher.fetchMultipleContentAsync(from: urls)
+
+ completeResponses(
+ request,
+ responses: results,
+ completion: completion
+ )
+ }
+
+ return true
+ }
+}
diff --git a/Core/Sources/ChatService/ToolCalls/GetErrorsTool.swift b/Core/Sources/ChatService/ToolCalls/GetErrorsTool.swift
new file mode 100644
index 00000000..3a464016
--- /dev/null
+++ b/Core/Sources/ChatService/ToolCalls/GetErrorsTool.swift
@@ -0,0 +1,75 @@
+import JSONRPC
+import Foundation
+import ConversationServiceProvider
+import XcodeInspector
+import AppKit
+
+public class GetErrorsTool: ICopilotTool {
+ public func invokeTool(
+ _ request: InvokeClientToolRequest,
+ completion: @escaping (AnyJSONRPCResponse) -> Void,
+ contextProvider: ToolContextProvider?
+ ) -> Bool {
+ guard let params = request.params,
+ let input = params.input,
+ let filePaths = input["filePaths"]?.value as? [String]
+ else {
+ completeResponse(request, completion: completion)
+ return true
+ }
+
+ guard let xcodeInstance = XcodeInspector.shared.xcodes.first(
+ where: {
+ $0.workspaceURL?.path == contextProvider?.chatTabInfo.workspacePath
+ }),
+ let documentURL = xcodeInstance.realtimeDocumentURL,
+ filePaths.contains(where: { URL(fileURLWithPath: $0) == documentURL })
+ else {
+ completeResponse(request, completion: completion)
+ return true
+ }
+
+ /// Not leveraging the `getFocusedEditorContent` in `XcodeInspector`.
+ /// 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.isNonNavigatorSourceEditor {
+ focusedEditor = .init(runningApplication: xcodeInstance.runningApplication, element: editorElement)
+ } else if let element = focusedElement, let editorElement = element.firstParent(
+ where: \.isNonNavigatorSourceEditor
+ ) {
+ focusedEditor = .init(runningApplication: xcodeInstance.runningApplication, element: editorElement)
+ } else {
+ focusedEditor = nil
+ }
+
+ var errors: String = ""
+
+ if let focusedEditor
+ {
+ let editorContent = focusedEditor.getContent()
+ let errorArray: [String] = editorContent.lineAnnotations.map {
+ """
+ \(documentURL.absoluteString)
+
+ \($0.message)
+
+
+ \($0.line)
+ 0
+
+
+ \($0.line)
+ 0
+
+
+
+ """
+ }
+ errors = errorArray.joined(separator: "\n")
+ }
+
+ completeResponse(request, response: errors, completion: completion)
+ return true
+ }
+}
diff --git a/Core/Sources/ChatService/ToolCalls/GetTerminalOutputTool.swift b/Core/Sources/ChatService/ToolCalls/GetTerminalOutputTool.swift
new file mode 100644
index 00000000..69a76689
--- /dev/null
+++ b/Core/Sources/ChatService/ToolCalls/GetTerminalOutputTool.swift
@@ -0,0 +1,33 @@
+import ConversationServiceProvider
+import Foundation
+import JSONRPC
+import Terminal
+
+public class GetTerminalOutputTool: ICopilotTool {
+ 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)
+ result = session?.getCommandOutput() ?? "Terminal id \(terminalId) not found"
+ } else {
+ result = "Invalid arguments for \(ToolName.getTerminalOutput.rawValue) tool call"
+ }
+
+ let toolResult = LanguageModelToolResult(content: [
+ .init(value: result)
+ ])
+ let jsonResult = try? JSONEncoder().encode(toolResult)
+ let jsonValue = (try? JSONDecoder().decode(JSONValue.self, from: jsonResult ?? Data())) ?? JSONValue.null
+ completion(
+ AnyJSONRPCResponse(
+ id: request.id,
+ result: JSONValue.array([
+ jsonValue,
+ JSONValue.null
+ ])
+ )
+ )
+
+ return true
+ }
+}
diff --git a/Core/Sources/ChatService/ToolCalls/ICopilotTool.swift b/Core/Sources/ChatService/ToolCalls/ICopilotTool.swift
new file mode 100644
index 00000000..8e10fbfa
--- /dev/null
+++ b/Core/Sources/ChatService/ToolCalls/ICopilotTool.swift
@@ -0,0 +1,91 @@
+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 protocol ICopilotTool {
+ /**
+ * Invokes the Copilot tool with the given request.
+ * - Parameters:
+ * - request: The tool invocation request.
+ * - completion: Closure called with JSON-RPC response when tool execution completes.
+ * - 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.
+ */
+ func invokeTool(
+ _ request: InvokeClientToolRequest,
+ completion: @escaping (AnyJSONRPCResponse) -> Void,
+ contextProvider: ToolContextProvider?
+ ) -> Bool
+}
+
+extension ICopilotTool {
+ /**
+ * Completes a tool response.
+ * - Parameters:
+ * - request: The original tool invocation request.
+ * - status: The completion status of the tool execution (success, error, or cancelled).
+ * - response: The string value to include in the response content.
+ * - completion: The completion handler to call with the response.
+ */
+ func completeResponse(
+ _ request: InvokeClientToolRequest,
+ status: ToolInvocationStatus = .success,
+ response: String = "",
+ completion: @escaping (AnyJSONRPCResponse) -> Void
+ ) {
+ completeResponses(
+ request,
+ status: status,
+ responses: [response],
+ completion: completion
+ )
+ }
+
+ ///
+ /// Completes a tool response with multiple data entries.
+ /// - Parameters:
+ /// - request: The original tool invocation request.
+ /// - status: The completion status of the tool execution (success, error, or cancelled).
+ /// - responses: Array of string values to include in the response content.
+ /// - completion: The completion handler to call with the response.
+ ///
+ func completeResponses(
+ _ request: InvokeClientToolRequest,
+ status: ToolInvocationStatus = .success,
+ responses: [String],
+ completion: @escaping (AnyJSONRPCResponse) -> Void
+ ) {
+ let toolResult = LanguageModelToolResult(status: status, content: responses.map { response in
+ LanguageModelToolResult.Content(value: response)
+ })
+ let jsonResult = try? JSONEncoder().encode(toolResult)
+ let jsonValue = (try? JSONDecoder().decode(JSONValue.self, from: jsonResult ?? Data())) ?? JSONValue.null
+ completion(
+ AnyJSONRPCResponse(
+ id: request.id,
+ result: JSONValue.array([
+ jsonValue,
+ JSONValue.null,
+ ])
+ )
+ )
+ }
+}
+
+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
new file mode 100644
index 00000000..2eb6b160
--- /dev/null
+++ b/Core/Sources/ChatService/ToolCalls/InsertEditIntoFileTool.swift
@@ -0,0 +1,309 @@
+import AppKit
+import AXExtension
+import AXHelper
+import ConversationServiceProvider
+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
+
+ public func invokeTool(
+ _ request: InvokeClientToolRequest,
+ completion: @escaping (AnyJSONRPCResponse) -> Void,
+ contextProvider: (any ToolContextProvider)?
+ ) -> Bool {
+ guard let params = request.params,
+ let input = request.params?.input,
+ let code = input["code"]?.value as? String,
+ let filePath = input["filePath"]?.value as? String,
+ let contextProvider
+ else {
+ completeResponse(request, status: .error, response: "Invalid parameters", completion: completion)
+ return true
+ }
+
+ do {
+ let fileURL = URL(fileURLWithPath: filePath)
+ let originalContent = try String(contentsOf: fileURL, encoding: .utf8)
+
+ InsertEditIntoFileTool.applyEdit(for: fileURL, content: code) { newContent, error in
+ if let error = error {
+ self.completeResponse(
+ request,
+ status: .error,
+ response: error.localizedDescription,
+ completion: completion
+ )
+ return
+ }
+
+ guard let newContent = newContent
+ else {
+ self.completeResponse(request, status: .error, response: "Failed to apply edit", completion: completion)
+ return
+ }
+
+ let fileEdit: FileEdit = .init(fileURL: fileURL, originalContent: originalContent, modifiedContent: code, toolName: InsertEditIntoFileTool.name)
+ contextProvider.updateFileEdits(by: fileEdit)
+
+ let editAgentRounds: [AgentRound] = [
+ .init(
+ roundId: params.roundId,
+ reply: "",
+ toolCalls: [
+ .init(
+ id: params.toolCallId,
+ name: params.name,
+ status: .completed,
+ invokeParams: params
+ )
+ ]
+ )
+ ]
+
+ contextProvider
+ .updateChatHistory(params.turnId, editAgentRounds: editAgentRounds, fileEdits: [fileEdit])
+
+ self.completeResponse(request, response: newContent, completion: completion)
+ }
+
+ } catch {
+ completeResponse(
+ request,
+ status: .error,
+ response: error.localizedDescription,
+ completion: completion
+ )
+ }
+
+ return true
+ }
+
+ public static func applyEdit(
+ for fileURL: URL,
+ content: String,
+ xcodeInstance: AppInstanceInspector
+ ) throws -> String {
+ guard let editorElement = Self.getEditorElement(by: xcodeInstance, for: fileURL)
+ else {
+ throw InsertEditError.missingEditorElement(file: fileURL)
+ }
+
+ // Check if element supports kAXValueAttribute before reading
+ var value: String = ""
+ do {
+ value = try editorElement.copyValue(key: kAXValueAttribute)
+ } catch {
+ if let axError = error as? AXError {
+ Logger.client.error("AX Error code: \(axError.rawValue)")
+ }
+ throw error
+ }
+
+ let lines = value.components(separatedBy: .newlines)
+
+ do {
+ try Self.checkOpenedFileURL(for: fileURL, xcodeInstance: xcodeInstance)
+
+ try AXHelper().injectUpdatedCodeWithAccessibilityAPI(
+ .init(
+ content: content,
+ newSelection: nil,
+ modifications: [
+ .deletedSelection(
+ .init(start: .init(line: 0, character: 0), end: .init(line: lines.count - 1, character: (lines.last?.count ?? 100) - 1))
+ ),
+ .inserted(0, [content])
+ ]
+ ),
+ focusElement: editorElement
+ )
+ } catch {
+ 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
+ }
+
+ return editorElement
+ }
+
+ 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)
+ }
+
+ 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 {
+ guard let diskFileContent = try? String(contentsOf: fileURL) else {
+ throw InsertEditError.fileNotAccessible(fileURL)
+ }
+
+ 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)")
+ }
+ }
+
+ static func applyEditWithAccessibilityAPI(
+ for fileURL: URL,
+ content: String,
+ completion: ((String?, Error?) -> Void)? = nil,
+ ) {
+ NSWorkspace.openFileInXcode(fileURL: fileURL) { app, error in
+ do {
+ if let error = error { throw error }
+
+ guard let app = app
+ else {
+ throw InsertEditError.openingApplicationUnavailable
+ }
+
+ let appInstanceInspector = AppInstanceInspector(runningApplication: app)
+ guard appInstanceInspector.isXcode
+ else {
+ throw InsertEditError.fileNotOpenedInXcode
+ }
+
+ let newContent = try applyEdit(
+ for: fileURL,
+ content: content,
+ xcodeInstance: appInstanceInspector
+ )
+
+ Task {
+ await WorkspaceInvocationCoordinator().invokeFilespaceUpdate(fileURL: fileURL, content: newContent)
+ if let completion = completion { completion(newContent, nil) }
+ }
+ } catch {
+ if let completion = completion { completion(nil, error) }
+ Logger.client.info("Failed to apply edit for file at \(fileURL), \(error)")
+ }
+ }
+ }
+}
diff --git a/Core/Sources/ChatService/ToolCalls/RunInTerminalTool.swift b/Core/Sources/ChatService/ToolCalls/RunInTerminalTool.swift
new file mode 100644
index 00000000..1fc8306b
--- /dev/null
+++ b/Core/Sources/ChatService/ToolCalls/RunInTerminalTool.swift
@@ -0,0 +1,42 @@
+import ConversationServiceProvider
+import Terminal
+import XcodeInspector
+import JSONRPC
+
+public class RunInTerminalTool: ICopilotTool {
+ public func invokeTool(_ request: InvokeClientToolRequest, completion: @escaping (AnyJSONRPCResponse) -> Void, contextProvider: (any ToolContextProvider)?) -> Bool {
+ let params = request.params!
+
+ Task {
+ var currentDirectory: String = ""
+ if let workspacePath = contextProvider?.chatTabInfo.workspacePath,
+ let xcodeIntance = Utils.getXcode(by: workspacePath) {
+ currentDirectory = xcodeIntance.realtimeProjectURL?.path ?? xcodeIntance.projectRootURL?.path ?? ""
+ } else {
+ currentDirectory = await XcodeInspector.shared.safe.realtimeActiveProjectURL?.path ?? ""
+ }
+ if let input = params.input {
+ let command = input["command"]?.value as? String
+ let isBackground = input["isBackground"]?.value as? Bool
+ let toolId = params.toolCallId
+ let session = TerminalSessionManager.shared.createSession(for: toolId)
+ if isBackground == true {
+ session.executeCommand(
+ currentDirectory: currentDirectory,
+ command: command!) { result in
+ // do nothing
+ }
+ completeResponse(request, response: "Command is running in terminal with ID=\(toolId)", completion: completion)
+ } else {
+ session.executeCommand(
+ currentDirectory: currentDirectory,
+ command: command!) { result in
+ self.completeResponse(request, response: result.output, completion: completion)
+ }
+ }
+ }
+ }
+
+ return true
+ }
+}
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/ToolCalls/Utils.swift b/Core/Sources/ChatService/ToolCalls/Utils.swift
new file mode 100644
index 00000000..507714cf
--- /dev/null
+++ b/Core/Sources/ChatService/ToolCalls/Utils.swift
@@ -0,0 +1,14 @@
+import AppKit
+import AppKitExtension
+import Foundation
+import Logger
+import XcodeInspector
+
+class Utils {
+ public static func getXcode(by workspacePath: String) -> XcodeAppInstanceInspector? {
+ return XcodeInspector.shared.xcodes.first(
+ where: {
+ $0.workspaceURL?.path == workspacePath
+ })
+ }
+}
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 eaee3a54..4abf8187 100644
--- a/Core/Sources/ConversationTab/Chat.swift
+++ b/Core/Sources/ConversationTab/Chat.swift
@@ -5,31 +5,88 @@ import ChatAPIService
import Preferences
import Terminal
import ConversationServiceProvider
+import Persist
+import GitHubCopilotService
+import Logger
+import OrderedCollections
+import SwiftUI
+import GitHelper
+import SuggestionBasic
+import HostAppActivator
public struct DisplayedChatMessage: Equatable {
public enum Role: Equatable {
case user
case assistant
- case system
case ignored
}
public var id: String
public var role: Role
public var text: String
+ public var imageReferences: [ImageReference] = []
public var references: [ConversationReference] = []
public var followUp: ConversationFollowUp? = nil
public var suggestedTitle: String? = nil
- public var errorMessage: 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, role: Role, text: String, references: [ConversationReference] = [], followUp: ConversationFollowUp? = nil, suggestedTitle: String? = nil, errorMessage: String? = nil) {
+ public init(
+ id: String,
+ role: Role,
+ text: String,
+ imageReferences: [ImageReference] = [],
+ references: [ConversationReference] = [],
+ followUp: ConversationFollowUp? = nil,
+ suggestedTitle: String? = nil,
+ errorMessages: [String] = [],
+ steps: [ConversationProgressStep] = [],
+ thinking: [MessageThinking] = [],
+ editAgentRounds: [AgentRound] = [],
+ parentTurnId: String? = nil,
+ panelMessages: [CopilotShowMessageParams] = [],
+ codeReviewRound: CodeReviewRound? = nil,
+ fileEdits: [FileEdit] = [],
+ turnStatus: ChatMessage.TurnStatus? = nil,
+ requestType: RequestType,
+ modelName: String? = nil,
+ modelProviderName: String? = nil,
+ billingMultiplier: Float? = nil,
+ reasoningEffort: String? = nil
+ ) {
self.id = id
self.role = role
self.text = text
+ self.imageReferences = imageReferences
self.references = references
self.followUp = followUp
self.suggestedTitle = suggestedTitle
- self.errorMessage = errorMessage
+ 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
}
}
@@ -37,25 +94,495 @@ private var isPreview: Bool {
ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
}
+struct ChatContext: Equatable {
+ var typedMessage: String
+ var attachedReferences: [ConversationAttachedReference]
+ var attachedImages: [ImageReference]
+
+ init(typedMessage: String, attachedReferences: [ConversationAttachedReference] = [], attachedImages: [ImageReference] = []) {
+ self.typedMessage = typedMessage
+ self.attachedReferences = attachedReferences
+ self.attachedImages = attachedImages
+ }
+
+ static func empty() -> ChatContext {
+ .init(typedMessage: "", attachedReferences: [], attachedImages: [])
+ }
+
+ static func from(_ message: DisplayedChatMessage, projectURL: URL) -> ChatContext {
+ .init(
+ typedMessage: message.text,
+ attachedReferences: message.references.compactMap {
+ guard let url = $0.url else { return nil }
+ if $0.isDirectory {
+ return .directory(.init(url: url))
+ } else {
+ let relativePath = url.path.replacingOccurrences(of: projectURL.path, with: "")
+ let fileName = url.lastPathComponent
+ return .file(.init(url: url, relativePath: relativePath, fileName: fileName))
+ }
+ },
+ attachedImages: message.imageReferences)
+ }
+}
+
+struct ChatContextProvider: Equatable {
+ var contextStack: [ChatContext]
+
+ init(contextStack: [ChatContext] = []) {
+ self.contextStack = contextStack
+ }
+
+ mutating func reset() {
+ contextStack = []
+ }
+
+ mutating func getNextContext() -> ChatContext? {
+ guard !contextStack.isEmpty else {
+ return nil
+ }
+
+ return contextStack.removeLast()
+ }
+
+ func getPreviousContext(from history: [DisplayedChatMessage], projectURL: URL) -> ChatContext? {
+ let previousUserMessage: DisplayedChatMessage? = {
+ let userMessages = history.filter { $0.role == .user }
+ guard !userMessages.isEmpty else {
+ return nil
+ }
+
+ let stackCount = contextStack.count
+ guard userMessages.count > stackCount else {
+ return nil
+ }
+
+ let index = userMessages.count - stackCount - 1
+ guard index >= 0 else { return nil }
+
+ return userMessages[index]
+ }()
+
+ var context: ChatContext?
+ if let previousUserMessage {
+ context = .from(previousUserMessage, projectURL: projectURL)
+ }
+
+ return context
+ }
+
+ mutating func pushContext(_ context: ChatContext) {
+ contextStack.append(context)
+ }
+}
+
@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 State: Equatable {
- var title: String = "New Chat"
- var isTitleSet: Bool = false
+ struct EditorState: Equatable {
+ enum Field: String, Hashable {
+ case textField
+ case fileSearchBar
+ }
+
+ var codeReviewState = ConversationCodeReviewFeature.State()
- var typedMessage = ""
- var history: [DisplayedChatMessage] = []
- var isReceivingMessage = false
- var chatMenu = ChatMenu.State()
+ var mode: EditorMode
+ var contexts: [EditorMode: ChatContext]
+ var contextProvider: ChatContextProvider
var focusedField: Field?
- var currentEditor: FileReference? = nil
- var selectedFiles: [FileReference] = []
+ var currentEditor: ConversationFileReference?
+ var handOffClicked: Bool = false
- enum Field: String, Hashable {
- case textField
+ 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
+ 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 {
+ editor.updateCurrentContext { $0.typedMessage = newValue }
+ editor.resetContextProvider()
+ }
+ }
+
+ 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)
+ }
+
+ // 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)
+
+ // Chat Context
+ case reloadNextContext
+ case reloadPreviousContext
+ case resetContextProvider
+
+ // 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
@@ -104,9 +677,14 @@ struct Chat {
case observeHistoryChange(UUID)
case observeIsReceivingMessageChange(UUID)
case sendMessage(UUID)
+ case observeFileEditChange(UUID)
+ case observeContextSizeInfoChange(UUID)
+ case observeFixErrorNotification(UUID)
}
@Dependency(\.openURL) var openURL
+ @AppStorage(\.enableCurrentEditorContext) var enableCurrentEditorContext: Bool
+ @AppStorage(\.chatResponseLocale) var chatResponseLocale
var body: some ReducerOf {
BindingReducer()
@@ -114,6 +692,10 @@ struct Chat {
Scope(state: \.chatMenu, action: /Action.chatMenu) {
ChatMenu(service: service)
}
+
+ Scope(state: \.codeReviewState, action: /Action.codeReview) {
+ ConversationCodeReviewFeature(service: service)
+ }
Reduce { state, action in
switch action {
@@ -125,6 +707,25 @@ struct Chat {
await send(.isReceivingMessageChanged)
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))
+ }
+ }
}
case .refresh:
@@ -135,28 +736,179 @@ struct Chat {
case let .sendButtonTapped(id):
guard !state.typedMessage.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return .none }
let message = state.typedMessage
- let skillSet = state.buildSkillSet()
+ let skillSet = state.buildSkillSet(
+ isCurrentEditorContextEnabled: enableCurrentEditorContext
+ )
state.typedMessage = ""
- let selectedFiles = state.selectedFiles
+ let selectedModel = AppState.shared.getSelectedModel()
+ 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.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(
+ id,
+ content: message,
+ contentImageReferences: attachedImages,
+ skillSet: skillSet,
+ references: references,
+ model: selectedModelFamily,
+ modelProviderName: selectedModel?.providerName,
+ reasoningEffort: reasoningEffort,
+ agentMode: agentMode,
+ customChatModeId: selectedAgentSubMode,
+ userLanguage: chatResponseLocale
+ )
+ }.cancellable(id: CancelID.sendMessage(self.id))
+
+ case let .toolCallAccepted(toolCallId):
+ guard !toolCallId.isEmpty else { return .none }
+ 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
+ service.updateToolCallStatus(toolCallId: toolCallId, status: .cancelled)
+ }.cancellable(id: CancelID.sendMessage(self.id))
+ case let .toolCallCompleted(toolCallId, result):
+ guard !toolCallId.isEmpty else { return .none }
return .run { _ in
- try await service.send(id, content: message, skillSet: skillSet, references: selectedFiles)
+ service.updateToolCallStatus(toolCallId: toolCallId, status: .completed, payload: result)
}.cancellable(id: CancelID.sendMessage(self.id))
case let .followUpButtonClicked(id, message):
guard !message.isEmpty else { return .none }
- let skillSet = state.buildSkillSet()
+ let skillSet = state.buildSkillSet(
+ isCurrentEditorContextEnabled: enableCurrentEditorContext
+ )
- let selectedFiles = state.selectedFiles
+ let selectedModel = AppState.shared.getSelectedModel()
+ let selectedModelFamily = selectedModel?.modelFamily ?? CopilotModelManager.getDefaultChatModel(
+ scope: AppState.shared.modelScope()
+ )?.modelFamily
+ let references = state.attachedReferences
+ let agentMode = AppState.shared.isAgentModeEnabled()
+ let selectedAgentSubMode = AppState.shared.getSelectedAgentSubMode()
- return .run { _ in
- try await service.send(id, content: message, skillSet: skillSet, references: selectedFiles)
+ return .run { send in
+ await send(.resetContextProvider)
+ await send(.discardCheckPoint)
+
+ try await service
+ .send(
+ id,
+ content: message,
+ skillSet: skillSet,
+ 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(
@@ -173,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):
@@ -198,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)
@@ -218,6 +972,8 @@ struct Chat {
return .run { send in
await send(.observeHistoryChange)
await send(.observeIsReceivingMessageChange)
+ await send(.observeFileEditChange)
+ await send(.observeContextSizeInfoChange)
}
case .observeHistoryChange:
@@ -243,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()
}
@@ -257,6 +1014,44 @@ struct Chat {
id: CancelID.observeIsReceivingMessageChange(id),
cancelInFlight: true
)
+
+ case .observeFileEditChange:
+ return .run { send in
+ let stream = AsyncStream { continuation in
+ let cancellable = service.$fileEditMap
+ .sink { _ in
+ continuation.yield()
+ }
+ continuation.onTermination = { _ in
+ cancellable.cancel()
+ }
+ }
+ for await _ in stream {
+ await send(.fileEditChanged)
+ }
+ }.cancellable(
+ id: CancelID.observeFileEditChange(id),
+ 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
@@ -265,47 +1060,90 @@ struct Chat {
id: message.id,
role: {
switch message.role {
- case .system: return .system
case .user: return .user
case .assistant: return .assistant
+ case .system: return .ignored
}
}(),
text: message.content,
+ imageReferences: message.contentImageReferences,
references: message.references.map {
.init(
uri: $0.uri,
status: $0.status,
- kind: $0.kind
+ kind: $0.kind,
+ referenceType: $0.referenceType
)
},
followUp: message.followUp,
suggestedTitle: message.suggestedTitle,
- errorMessage: message.errorMessage
+ errorMessages: message.errorMessages,
+ steps: message.steps,
+ thinking: message.thinking,
+ editAgentRounds: message.editAgentRounds,
+ parentTurnId: message.parentTurnId,
+ panelMessages: message.panelMessages,
+ codeReviewRound: message.codeReviewRound,
+ fileEdits: message.fileEdits,
+ turnStatus: message.turnStatus,
+ requestType: message.requestType,
+ modelName: message.modelName,
+ modelProviderName: message.modelProviderName,
+ billingMultiplier: message.billingMultiplier,
+ reasoningEffort: message.reasoningEffort
))
return all
}
- guard let lastChatMessage = state.history.last else { return .none }
- return .run { send in
- await send(.setTitle(lastChatMessage))
- }
-
- case let .setTitle(message):
- guard state.isTitleSet == false,
- message.role == .assistant,
- let suggestedTitle = message.suggestedTitle
- else { return .none }
-
- state.title = suggestedTitle
- state.isTitleSet = true
-
return .none
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
+
+ let diffViewerController = state.diffViewerController
+
+ return .run { _ in
+ /// refresh diff view
+
+ guard let diffViewerController,
+ diffViewerController.diffViewerState == .shown
+ else { return }
+
+ if fileEditMap.isEmpty {
+ await diffViewerController.hideWindow()
+ return
+ }
+
+ guard let currentFileEdit = diffViewerController.currentFileEdit
+ else { return }
+
+ if let updatedFileEdit = fileEditMap[currentFileEdit.fileURL] {
+ if updatedFileEdit != currentFileEdit {
+ if updatedFileEdit.status == .undone,
+ updatedFileEdit.toolName == .createFile
+ {
+ await diffViewerController.hideWindow()
+ } else {
+ await diffViewerController.showDiffWindow(fileEdit: updatedFileEdit)
+ }
+ }
+ } else {
+ await diffViewerController.hideWindow()
+ }
+ }
+
case .binding:
return .none
@@ -328,20 +1166,336 @@ struct Chat {
ChatInjector().insertCodeBlock(codeBlock: code)
return .none
- case let .addSelectedFile(fileReference):
- guard !state.selectedFiles.contains(fileReference) else { return .none }
- state.selectedFiles.append(fileReference)
- return .none
- case let .removeSelectedFile(fileReference):
- guard let index = state.selectedFiles.firstIndex(of: fileReference) else { return .none }
- state.selectedFiles.remove(at: index)
- return .none
+ // MARK: - Context
case .resetCurrentEditor:
state.currentEditor = nil
return .none
case let .setCurrentEditor(fileReference):
state.currentEditor = fileReference
return .none
+ case let .addReference(ref):
+ state.editor.addReference(ref)
+ return .none
+
+ case let .removeReference(ref):
+ state.editor.removeReference(ref)
+ return .none
+
+ // MARK: - Image Context
+ case let .addSelectedImage(imageReference):
+ guard !state.attachedImages.contains(imageReference) else { return .none }
+ state.editor.addImage(imageReference)
+ return .run { send in await send(.resetContextProvider) }
+ case let .removeSelectedImage(imageReference):
+ guard let _ = state.attachedImages.firstIndex(of: imageReference) else { return .none }
+ state.editor.removeImage(imageReference)
+ return .run { send in await send(.resetContextProvider) }
+
+ // MARK: - Agent Edits
+
+ case let .undoEdits(fileURLs):
+ for fileURL in fileURLs {
+ do {
+ try service.undoFileEdit(for: fileURL)
+ } catch {
+ Logger.service.error("Failed to undo edit, \(error)")
+ }
+ }
+
+ return .none
+
+ case let .keepEdits(fileURLs):
+ for fileURL in fileURLs {
+ service.keepFileEdit(for: fileURL)
+ }
+
+ return .none
+
+ case .resetEdits:
+ service.resetFileEdits()
+
+ return .none
+
+ case let .discardFileEdits(fileURLs):
+ for fileURL in fileURLs {
+ try? service.discardFileEdit(for: fileURL)
+ }
+ return .none
+
+ case let .openDiffViewWindow(fileURL):
+ guard let fileEdit = state.fileEditMap[fileURL],
+ let diffViewerController = state.diffViewerController
+ else { return .none }
+
+ return .run { _ in
+ await diffViewerController.showDiffWindow(fileEdit: fileEdit)
+ }
+
+ case let .setDiffViewerController(chat):
+ state.diffViewerController = .init(chat: chat)
+ return .none
+
+ 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.editor.popNextContext() else {
+ return .none
+ }
+
+ state.chatContext = context
+
+ return .run { send in
+ await send(.focusOnTextField)
+ }
+
+ case .reloadPreviousContext:
+ guard let projectURL = service.getProjectRootURL(),
+ let context = state.editor.previousContext(
+ from: state.history,
+ projectURL: projectURL)
+ else {
+ return .none
+ }
+
+ let currentContext = state.chatContext
+ state.chatContext = context
+ state.editor.pushContext(currentContext)
+
+ return .run { send in
+ await send(.focusOnTextField)
+ }
+
+ case .resetContextProvider:
+ state.editor.resetContextProvider()
+ return .none
+
+ // MARK: - External action
+
+ case .observeFixErrorNotification:
+ return .run { send in
+ let publisher = NotificationCenter.default.publisher(for: .fixEditorErrorIssue)
+
+ for await notification in publisher.values {
+ guard service.chatTabInfo.isSelected,
+ let issue = notification.userInfo?["editorErrorIssue"] as? EditorErrorIssue
+ else {
+ continue
+ }
+
+ await send(.fixEditorErrorIssue(issue))
+ }
+ }.cancellable(
+ id: CancelID.observeFixErrorNotification(id),
+ cancelInFlight: true)
+
+ case .fixEditorErrorIssue(let issue):
+ guard issue.workspaceURL == service.getWorkspaceURL(),
+ !issue.lineAnnotations.isEmpty
+ else {
+ return .none
+ }
+
+ guard !state.isReceivingMessage else {
+ return .run { _ in
+ await MainActor.run {
+ NotificationCenter.default.post(
+ name: .fixEditorErrorIssueError,
+ object: nil,
+ userInfo: ["error": FixEditorErrorIssueFailure.isReceivingMessage(id: issue.id)]
+ )
+ }
+ }
+ }
+
+ let errorAnnotationMessage: String = issue.lineAnnotations
+ .map { "❗\($0.originalAnnotation)" }
+ .joined(separator: "\n\n")
+ let message = "Analyze and fix the following error(s): \n\n\(errorAnnotationMessage)"
+
+ let skillSet = state.buildSkillSet(isCurrentEditorContextEnabled: enableCurrentEditorContext)
+ let references: [ConversationAttachedReference] = [.file(.init(url: issue.fileURL))]
+ let selectedModel = AppState.shared.getSelectedModel()
+ let selectedModelFamily = selectedModel?.modelFamily ?? CopilotModelManager.getDefaultChatModel(
+ scope: AppState.shared.modelScope()
+ )?.modelFamily
+ let agentMode = AppState.shared.isAgentModeEnabled()
+ // TODO: if we need to switch to agent mode or keep the current mode
+ let selectedAgentSubMode = AppState.shared.getSelectedAgentSubMode()
+
+ return .run { _ in
+ try await service.send(
+ UUID().uuidString,
+ content: message,
+ skillSet: skillSet,
+ 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()
+ }
}
}
}
@@ -415,3 +1569,31 @@ private actor TimedDebounceFunction {
await block()
}
}
+
+public struct EditorErrorIssue: Equatable {
+ public let lineAnnotations: [EditorInformation.LineAnnotation]
+ public let fileURL: URL
+ public let workspaceURL: URL
+ public let id: String
+
+ public init(
+ lineAnnotations: [EditorInformation.LineAnnotation],
+ fileURL: URL,
+ workspaceURL: URL,
+ id: String
+ ) {
+ self.lineAnnotations = lineAnnotations
+ self.fileURL = fileURL
+ self.workspaceURL = workspaceURL
+ self.id = id
+ }
+}
+
+public enum FixEditorErrorIssueFailure: Equatable {
+ case isReceivingMessage(id: String)
+}
+
+public extension Notification.Name {
+ static let fixEditorErrorIssue = Notification.Name("com.github.CopilotForXcode.fixEditorErrorIssue")
+ static let fixEditorErrorIssueError = Notification.Name("com.github.CopilotForXcode.fixEditorErrorIssueError")
+}
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/ChatDropdownView.swift b/Core/Sources/ConversationTab/ChatDropdownView.swift
new file mode 100644
index 00000000..bdd12f50
--- /dev/null
+++ b/Core/Sources/ConversationTab/ChatDropdownView.swift
@@ -0,0 +1,129 @@
+import ConversationServiceProvider
+import AppKit
+import SwiftUI
+import ComposableArchitecture
+
+protocol DropDownItem: Equatable {
+ var id: String { get }
+ var displayName: String { get }
+ var displayDescription: String { get }
+}
+
+extension ChatTemplate: DropDownItem {
+ var displayName: String { id }
+ var displayDescription: String { description }
+}
+
+extension ChatAgent: DropDownItem {
+ var id: String { slug }
+ var displayName: String { slug }
+ var displayDescription: String { description }
+}
+
+struct ChatDropdownView: View {
+ @Binding var items: [T]
+ let prefixSymbol: String
+ let onSelect: (T) -> Void
+ @State private var selectedIndex = 0
+ @State private var frameHeight: CGFloat = 0
+ @State private var localMonitor: Any? = nil
+
+ public var body: some View {
+ WithPerceptionTracking {
+ VStack(alignment: .leading, spacing: 0) {
+ ForEach(Array(items.enumerated()), id: \.element.id) { index, item in
+ HStack {
+ Text(prefixSymbol + item.displayName)
+ .hoverPrimaryForeground(isHovered: selectedIndex == index)
+ Spacer()
+ Text(item.displayDescription)
+ .hoverSecondaryForeground(isHovered: selectedIndex == index)
+ }
+ .padding(.horizontal, 8)
+ .padding(.vertical, 6)
+ .contentShape(Rectangle())
+ .onTapGesture {
+ onSelect(item)
+ }
+ .hoverBackground(isHovered: selectedIndex == index)
+ .onHover { isHovered in
+ if isHovered {
+ selectedIndex = index
+ }
+ }
+ }
+ }
+ .background(
+ GeometryReader { geometry in
+ Color.clear
+ .onAppear { frameHeight = geometry.size.height }
+ .onChange(of: geometry.size.height) { newHeight in
+ frameHeight = newHeight
+ }
+ }
+ )
+ .background(.ultraThickMaterial)
+ .cornerRadius(6)
+ .shadow(radius: 2)
+ .overlay(
+ RoundedRectangle(cornerRadius: 6)
+ .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
+ )
+ .frame(maxWidth: .infinity)
+ .offset(y: -1 * frameHeight)
+ .onChange(of: items) { _ in
+ selectedIndex = 0
+ }
+ .onAppear {
+ selectedIndex = 0
+ localMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
+ switch event.keyCode {
+ case 126: // Up arrow
+ moveSelection(up: true)
+ return nil
+ case 125: // Down arrow
+ moveSelection(up: false)
+ return nil
+ case 36: // Return key
+ handleEnter()
+ return nil
+ case 48: // Tab key
+ handleTab()
+ return nil // not forwarding the Tab Event which will replace the typed message to "\t"
+ default:
+ break
+ }
+ return event
+ }
+ }
+ .onDisappear {
+ if let monitor = localMonitor {
+ NSEvent.removeMonitor(monitor)
+ localMonitor = nil
+ }
+ }
+ }
+ }
+
+ private func moveSelection(up: Bool) {
+ guard !items.isEmpty else { return }
+ let lowerBound = 0
+ let upperBound = items.count - 1
+ let newIndex = selectedIndex + (up ? -1 : 1)
+ selectedIndex = newIndex < lowerBound ? upperBound : (newIndex > upperBound ? lowerBound : newIndex)
+ }
+
+ private func handleEnter() {
+ handleTemplateSelection()
+ }
+
+ private func handleTab() {
+ handleTemplateSelection()
+ }
+
+ private func handleTemplateSelection() {
+ if items.count > 0 && selectedIndex < items.count {
+ onSelect(items[selectedIndex])
+ }
+ }
+}
diff --git a/Core/Sources/ConversationTab/ChatExtension.swift b/Core/Sources/ConversationTab/ChatExtension.swift
index 0e3537b1..f5e2573f 100644
--- a/Core/Sources/ConversationTab/ChatExtension.swift
+++ b/Core/Sources/ConversationTab/ChatExtension.swift
@@ -2,16 +2,25 @@ import ChatService
import ConversationServiceProvider
extension Chat.State {
- func buildSkillSet() -> [ConversationSkill] {
- guard let currentFile = self.currentEditor else {
+ func buildSkillSet(isCurrentEditorContextEnabled: Bool) -> [ConversationSkill] {
+ guard let currentFile = self.currentEditor, isCurrentEditorContextEnabled else {
return []
}
- let fileReference = FileReference(
+ let fileReference = ConversationFileReference(
url: currentFile.url,
relativePath: currentFile.relativePath,
fileName: currentFile.fileName,
- isCurrentEditor: currentFile.isCurrentEditor
+ isCurrentEditor: currentFile.isCurrentEditor,
+ selection: currentFile.selection
)
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 ff231d6a..54026159 100644
--- a/Core/Sources/ConversationTab/ChatPanel.swift
+++ b/Core/Sources/ConversationTab/ChatPanel.swift
@@ -9,47 +9,129 @@ import SwiftUI
import ChatService
import SwiftUIFlowLayout
import XcodeInspector
+import ChatTab
+import Workspace
+import Persist
+import UniformTypeIdentifiers
+import Status
+import GitHubCopilotService
+import GitHubCopilotViewModel
+import LanguageServerProtocol
-private let r: Double = 8
+private let r: Double = 4
public struct ChatPanel: View {
- let chat: StoreOf
+ @Perception.Bindable var chat: StoreOf
@Namespace var inputAreaNamespace
+ @ObservedObject private var warningManager = WarningStateManager.shared
public var body: some View {
- VStack(spacing: 0) {
-
- if chat.history.isEmpty {
- VStack {
- Spacer()
- Instruction()
- Spacer()
- }
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
- .padding(.leading, -16)
- } else {
- ChatPanelMessages(chat: chat)
+ WithPerceptionTracking {
+ VStack(spacing: 0) {
- if chat.history.last?.role == .system {
- ChatCLSError(chat: chat).padding(.trailing, 16)
+ if chat.history.isEmpty {
+ VStack {
+ Spacer()
+ Instruction(isAgentMode: $chat.isAgentMode)
+ Spacer()
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
} else {
- ChatFollowUp(chat: chat)
- .padding(.trailing, 16)
- .padding(.vertical, 8)
+ ChatPanelMessages(chat: chat)
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel("Chat Messages Group")
+
+ if chat.isAgentMode, let handOffs = chat.selectedAgent.handOffs, !handOffs.isEmpty,
+ chat.history.contains(where: { $0.role == .assistant && $0.turnStatus != .inProgress }),
+ !chat.handOffClicked {
+ ChatHandOffs(chat: chat)
+ .scaledPadding(.vertical, 8)
+ .scaledPadding(.horizontal, 16)
+ .dimWithExitEditMode(chat)
+ } else if let _ = chat.history.last?.followUp {
+ ChatFollowUp(chat: chat)
+ .scaledPadding(.vertical, 8)
+ .scaledPadding(.horizontal, 16)
+ .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)
+ .dimWithExitEditMode(chat)
+ .scaledPadding(.horizontal, 24)
}
+
+ ChatPanelInputArea(chat: chat, r: r, editorMode: .input)
+ .dimWithExitEditMode(chat)
+ .scaledPadding(.horizontal, 16)
+ }
+ .scaledPadding(.vertical, 12)
+ .background(Color.chatWindowBackgroundColor)
+ .onAppear {
+ chat.send(.appear)
+ }
+ .onDrop(of: [.fileURL], isTargeted: nil) { providers in
+ onFileDrop(providers)
}
-
- ChatPanelInputArea(chat: chat)
- .padding(.trailing, 16)
}
- .padding(.leading, 16)
- .padding(.bottom, 16)
- .background(Color(nsColor: .windowBackgroundColor))
- .onAppear { chat.send(.appear) }
+ }
+
+ private func onFileDrop(_ providers: [NSItemProvider]) -> Bool {
+ let fileManager = FileManager.default
+
+ for provider in providers {
+ if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) {
+ provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier) { item, error in
+ let url: URL? = {
+ if let data = item as? Data {
+ return URL(dataRepresentation: data, relativeTo: nil)
+ } else if let url = item as? URL {
+ return url
+ }
+ return nil
+ }()
+
+ guard let url else { return }
+
+ var isDirectory: ObjCBool = false
+ if let isValidFile = try? WorkspaceFile.isValidFile(url), isValidFile {
+ DispatchQueue.main.async {
+ let fileReference = ConversationFileReference(url: url, isCurrentEditor: false)
+ chat.send(.addReference(.file(fileReference)))
+ }
+ } else if let data = try? Data(contentsOf: url),
+ ["png", "jpeg", "jpg", "bmp", "gif", "tiff", "tif", "webp"].contains(url.pathExtension.lowercased()) {
+ DispatchQueue.main.async {
+ chat.send(.addSelectedImage(ImageReference(data: data, fileUrl: url)))
+ }
+ } else if fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory), isDirectory.boolValue {
+ DispatchQueue.main.async {
+ chat.send(.addReference(.directory(.init(url: url))))
+ }
+ }
+ }
+ }
+ }
+
+ return true
}
}
+
+
private struct ScrollViewOffsetPreferenceKey: PreferenceKey {
static var defaultValue = CGFloat.zero
@@ -66,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()
@@ -79,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
@@ -111,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(
@@ -144,11 +266,9 @@ struct ChatPanelMessages: View {
scrollOffset = value
updatePinningState()
}
- .overlay(alignment: .bottom) {
- StopRespondingButton(chat: chat)
- }
.overlay(alignment: .bottomTrailing) {
scrollToBottomButton(proxy: proxy)
+ .scaledPadding(4)
}
.background {
PinToBottomHandler(
@@ -195,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
}
}
@@ -213,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")
@@ -233,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)
}
}
}
@@ -264,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
@@ -274,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()
+ }
}
}
}
@@ -293,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
+ )
}
}
}
@@ -323,18 +506,22 @@ struct ChatHistoryItem: View {
let text = message.text
switch message.role {
case .user:
- UserMessage(id: message.id, text: text, chat: chat)
- case .assistant:
- BotMessage(
+ UserMessage(
id: message.id,
text: text,
- references: message.references,
- followUp: message.followUp,
- errorMessage: message.errorMessage,
+ imageReferences: message.imageReferences,
+ 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(
+ message: message,
chat: chat
)
- case .system:
- FunctionMessage(chat: chat, id: message.id, text: text)
+ .scaledPadding(.trailing, 20)
case .ignored:
EmptyView()
}
@@ -342,43 +529,6 @@ struct ChatHistoryItem: View {
}
}
-private struct StopRespondingButton: View {
- let chat: StoreOf
-
- var body: some View {
- WithPerceptionTracking {
- if chat.isReceivingMessage {
- Button(action: {
- chat.send(.stopRespondingButtonTapped)
- }) {
- HStack(spacing: 4) {
- Image(systemName: "stop.fill")
- Text("Stop Responding")
- }
- .padding(8)
- .background(
- .regularMaterial,
- in: RoundedRectangle(cornerRadius: r, style: .continuous)
- )
- .overlay {
- RoundedRectangle(cornerRadius: r, style: .continuous)
- .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
- }
- }
- .buttonStyle(.borderless)
- .frame(maxWidth: .infinity, alignment: .center)
- .padding(.bottom, 8)
- .opacity(chat.isReceivingMessage ? 1 : 0)
- .disabled(!chat.isReceivingMessage)
- .transformEffect(.init(
- translationX: 0,
- y: chat.isReceivingMessage ? 0 : 20
- ))
- }
- }
- }
-}
-
struct ChatFollowUp: View {
let chat: StoreOf
@AppStorage(\.chatFontSize) var chatFontSize
@@ -392,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)
@@ -443,372 +637,19 @@ 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)
- }
-
- 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: [FileReference] = []
- @State private var searchText = ""
- @State private var selectedFiles: [FileReference] = []
- @State private var filteredTemplates: [ChatTemplate] = []
- @State private var showingTemplates = false
-
- var body: some View {
- WithPerceptionTracking {
- VStack(spacing: 0) {
- ZStack(alignment: .topLeading) {
- if chat.typedMessage.isEmpty {
- Text("Ask Copilot")
- .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 (!showingTemplates) {
- submitChatMessage()
- }
- showingTemplates = false
- },
- completions: chatAutoCompletion
- )
- .focused(focusedField, equals: .textField)
- .bind($chat.focusedField, to: focusedField)
- .padding(8)
- .fixedSize(horizontal: false, vertical: true)
- .onChange(of: chat.typedMessage) { newValue in
- Task {
- filteredTemplates = await chatTemplateCompletion(text: newValue)
- showingTemplates = !filteredTemplates.isEmpty
- }
- }
- }
- .frame(maxWidth: .infinity)
- }
- .padding(.top, 4)
-
- attachedFilesView
-
- if isFilePickerPresented {
- filePickerView
- .transition(.move(edge: .bottom))
- .onAppear() {
- allFiles = ContextUtils.getFilesInActiveWorkspace()
- }
- }
-
- HStack(spacing: 0) {
- Button(action: {
- withAnimation {
- isFilePickerPresented.toggle()
- }
- }) {
- Image(systemName: "paperclip")
- .padding(4)
- }
- .buttonStyle(HoverButtonStyle(padding: 0))
- .help("Attach Context")
-
- Spacer()
-
- Button(action: {
- submitChatMessage()
- }) {
- Image(systemName: "paperplane.fill")
- .padding(4)
- }
- .buttonStyle(HoverButtonStyle(padding: 0))
- .disabled(chat.isReceivingMessage)
- .keyboardShortcut(KeyEquivalent.return, modifiers: [])
- .help("Send")
- }
- .padding(8)
- .padding(.top, -4)
- }
- .overlay(alignment: .top) {
- if showingTemplates {
- ChatTemplateDropdownView(templates: $filteredTemplates) { template in
- chat.typedMessage = "/" + template.id + " "
- }
- }
- }
- .onAppear() {
- subscribeToActiveDocumentChangeEvent()
- }
- .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])
-
- Button(action: {
- focusedField.wrappedValue = .textField
- }) {
- EmptyView()
- }
- .keyboardShortcut("l", modifiers: [.command])
- }
- }
- }
-
- private var attachedFilesView: some View {
- FlowLayout(mode: .scrollable, items: [chat.state.currentEditor] + chat.state.selectedFiles, itemSpacing: 4) { file in
- if let select = file {
- HStack(spacing: 4) {
- drawFileIcon(select.url)
- .resizable()
- .scaledToFit()
- .frame(width: 16, height: 16)
- .foregroundColor(.secondary)
-
- Text(select.url.lastPathComponent)
- .lineLimit(1)
- .truncationMode(.middle)
- .help(select.getPathRelativeToHome())
-
- Button(action: {
- if select.isCurrentEditor {
- chat.send(.resetCurrentEditor)
- } else {
- chat.send(.removeSelectedFile(select))
- }
- }) {
- Image(systemName: "xmark")
- .resizable()
- .frame(width: 8, height: 8)
- .foregroundColor(.secondary)
- }
- .buttonStyle(HoverButtonStyle())
- .help("Remove from Context")
- }
- .padding(4)
- .cornerRadius(6)
- .shadow(radius: 2)
-// .background(
-// RoundedRectangle(cornerRadius: r)
-// .fill(.ultraThickMaterial)
-// )
- .overlay(
- RoundedRectangle(cornerRadius: r)
- .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
- )
- }
- }
- .padding(.horizontal, 8)
- }
+extension URL {
+ func getPathRelativeToHome() -> String {
+ let filePath = self.path
+ guard !filePath.isEmpty else { return "" }
- private var filePickerView: some View {
- VStack(spacing: 8) {
- HStack {
- Image(systemName: "magnifyingglass")
- .foregroundColor(.secondary)
-
- TextField("Search files...", text: $searchText)
- .textFieldStyle(PlainTextFieldStyle())
- .foregroundColor(searchText.isEmpty ? Color(nsColor: .placeholderTextColor) : Color(nsColor: .textColor))
-
- Button(action: {
- withAnimation {
- isFilePickerPresented = false
- }
- }) {
- Image(systemName: "xmark.circle.fill")
- .foregroundColor(.secondary)
- }
- .buttonStyle(HoverButtonStyle())
- .help("Close")
- }
- .padding(8)
- .background(
- RoundedRectangle(cornerRadius: 10)
- .fill(Color.gray.opacity(0.1))
- )
- .cornerRadius(6)
- .padding(.horizontal, 4)
- .padding(.top, 4)
-
- ScrollView {
- LazyVStack(alignment: .leading, spacing: 4) {
- ForEach(filteredFiles, id: \.self) { doc in
- FileRowView(doc: doc)
- .contentShape(Rectangle())
- .onTapGesture {
- chat.send(.addSelectedFile(doc))
- }
- }
-
- if filteredFiles.isEmpty {
- Text("No results found")
- .foregroundColor(.secondary)
- .padding(.leading, 4)
- .padding(.vertical, 4)
- }
- }
- }
- .frame(maxHeight: 200)
- .padding(.horizontal, 4)
- .padding(.bottom, 4)
- }
- .fixedSize(horizontal: false, vertical: true)
- .cornerRadius(6)
- .shadow(radius: 2)
-// .background(
-// RoundedRectangle(cornerRadius: r)
-// .fill(.ultraThickMaterial)
-// )
- .overlay(
- RoundedRectangle(cornerRadius: r)
- .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
- )
- .padding(.horizontal, 12)
+ let homeDirectory = FileManager.default.homeDirectoryForCurrentUser.path
+ if !homeDirectory.isEmpty {
+ return filePath.replacingOccurrences(of: homeDirectory, with: "~")
}
- private var filteredFiles: [FileReference] {
- if searchText.isEmpty {
- return allFiles
- }
-
- return allFiles.filter { doc in
- (doc.fileName ?? doc.url.lastPathComponent) .localizedCaseInsensitiveContains(searchText)
- }
- }
-
- func chatTemplateCompletion(text: String) async -> [ChatTemplate] {
- guard text.count >= 1 && text.first == "/" else { return [] }
- let prefix = text.dropFirst()
- let templates = await ChatService.shared.loadChatTemplates() ?? []
- guard !templates.isEmpty else {
- return []
- }
-
- let skippedTemplates = [ "feedback", "help" ]
- return templates.filter { $0.scopes.contains(.chatPanel) &&
- $0.id.hasPrefix(prefix) && !skippedTemplates.contains($0.id)}
- }
-
- func chatAutoCompletion(text: String, proposed: [String], range: NSRange) -> [String] {
- guard text.count == 1 else { return [] }
- let plugins = [String]() // chat.pluginIdentifiers.map { "/\($0)" }
- let availableFeatures = plugins + [
-// "/exit",
- "@code",
- "@sense",
- "@project",
- "@web",
- ]
-
- let result: [String] = availableFeatures
- .filter { $0.hasPrefix(text) && $0 != text }
- .compactMap {
- guard let index = $0.index(
- $0.startIndex,
- offsetBy: range.location,
- limitedBy: $0.endIndex
- ) else { return nil }
- return String($0[index...])
- }
- return result
- }
- func subscribeToActiveDocumentChangeEvent() {
- XcodeInspector.shared.$activeDocumentURL.receive(on: DispatchQueue.main)
- .sink { newDocURL in
- if supportedFileExtensions.contains(newDocURL?.pathExtension ?? "") {
- let currentEditor = FileReference(url: newDocURL!, isCurrentEditor: true)
- chat.send(.setCurrentEditor(currentEditor))
- }
- }
- .store(in: &cancellable)
- }
-
- func submitChatMessage() {
- chat.send(.sendButtonTapped(UUID().uuidString))
- }
- }
-
- struct FileRowView: View {
- @State private var isHovered = false
- let doc: FileReference
-
- var body: some View {
- HStack {
- drawFileIcon(doc.url)
- .resizable()
- .frame(width: 16, height: 16)
- .foregroundColor(.secondary)
- .padding(.leading, 4)
-
- VStack(alignment: .leading) {
- Text(doc.fileName ?? doc.url.lastPathComponent)
- .font(.body)
- .hoverPrimaryForeground(isHovered: isHovered)
- Text(doc.relativePath ?? doc.url.path)
- .font(.caption)
- .foregroundColor(.secondary)
- }
-
- Spacer()
- }
- .padding(.vertical, 4)
- .hoverRadiusBackground(isHovered: isHovered, cornerRadius: 6)
- .onHover(perform: { hovering in
- isHovered = hovering
- })
- }
+ return filePath
}
}
-
// MARK: - Previews
struct ChatPanel_Preview: PreviewProvider {
@@ -817,7 +658,8 @@ struct ChatPanel_Preview: PreviewProvider {
id: "1",
role: .user,
text: "**Hello**",
- references: []
+ references: [],
+ requestType: .conversation
),
.init(
id: "2",
@@ -832,27 +674,32 @@ struct ChatPanel_Preview: PreviewProvider {
.init(
uri: "Hi Hi Hi Hi",
status: .included,
- kind: .class
+ 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",
@@ -872,14 +719,17 @@ 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
),
]
+
+ static let chatTabInfo = ChatTabInfo(id: "", workspacePath: "path", username: "name")
static var previews: some View {
ChatPanel(chat: .init(
initialState: .init(history: ChatPanel_Preview.history, isReceivingMessage: true),
- reducer: { Chat(service: ChatService.service()) }
+ reducer: { Chat(service: ChatService.service(for: chatTabInfo)) }
))
.frame(width: 450, height: 1200)
.colorScheme(.dark)
@@ -890,7 +740,7 @@ struct ChatPanel_EmptyChat_Preview: PreviewProvider {
static var previews: some View {
ChatPanel(chat: .init(
initialState: .init(history: [DisplayedChatMessage](), isReceivingMessage: false),
- reducer: { Chat(service: ChatService.service()) }
+ reducer: { Chat(service: ChatService.service(for: ChatPanel_Preview.chatTabInfo)) }
))
.padding()
.frame(width: 450, height: 600)
@@ -902,7 +752,7 @@ struct ChatPanel_InputText_Preview: PreviewProvider {
static var previews: some View {
ChatPanel(chat: .init(
initialState: .init(history: ChatPanel_Preview.history, isReceivingMessage: false),
- reducer: { Chat(service: ChatService.service()) }
+ reducer: { Chat(service: ChatService.service(for: ChatPanel_Preview.chatTabInfo)) }
))
.padding()
.frame(width: 450, height: 600)
@@ -915,12 +765,12 @@ struct ChatPanel_InputMultilineText_Preview: PreviewProvider {
ChatPanel(
chat: .init(
initialState: .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
),
- reducer: { Chat(service: ChatService.service()) }
+ reducer: { Chat(service: ChatService.service(for: ChatPanel_Preview.chatTabInfo)) }
)
)
.padding()
@@ -933,7 +783,7 @@ struct ChatPanel_Light_Preview: PreviewProvider {
static var previews: some View {
ChatPanel(chat: .init(
initialState: .init(history: ChatPanel_Preview.history, isReceivingMessage: true),
- reducer: { Chat(service: ChatService.service()) }
+ reducer: { Chat(service: ChatService.service(for: ChatPanel_Preview.chatTabInfo)) }
))
.padding()
.frame(width: 450, height: 600)
diff --git a/Core/Sources/ConversationTab/ChatTemplateDropdownView.swift b/Core/Sources/ConversationTab/ChatTemplateDropdownView.swift
deleted file mode 100644
index f99167aa..00000000
--- a/Core/Sources/ConversationTab/ChatTemplateDropdownView.swift
+++ /dev/null
@@ -1,105 +0,0 @@
-import ConversationServiceProvider
-import AppKit
-import SwiftUI
-
-public struct ChatTemplateDropdownView: View {
- @Binding var templates: [ChatTemplate]
- let onSelect: (ChatTemplate) -> Void
- @State private var selectedIndex = 0
- @State private var frameHeight: CGFloat = 0
- @State private var localMonitor: Any? = nil
-
- public var body: some View {
- VStack(alignment: .leading, spacing: 0) {
- ForEach(Array(templates.enumerated()), id: \.element.id) { index, template in
- HStack {
- Text("/" + template.id)
- .hoverPrimaryForeground(isHovered: selectedIndex == index)
- Spacer()
- Text(template.shortDescription)
- .hoverSecondaryForeground(isHovered: selectedIndex == index)
- }
- .padding(.horizontal, 8)
- .padding(.vertical, 6)
- .contentShape(Rectangle())
- .onTapGesture {
- onSelect(template)
- }
- .hoverBackground(isHovered: selectedIndex == index)
- .onHover { isHovered in
- if isHovered {
- selectedIndex = index
- }
- }
- }
- }
- .background(
- GeometryReader { geometry in
- Color.clear
- .onAppear { frameHeight = geometry.size.height }
- .onChange(of: geometry.size.height) { newHeight in
- frameHeight = newHeight
- }
- }
- )
- .background(.ultraThickMaterial)
- .cornerRadius(6)
- .shadow(radius: 2)
- .overlay(
- RoundedRectangle(cornerRadius: 6)
- .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
- )
- .frame(maxWidth: .infinity)
- .offset(y: -1 * frameHeight)
- .onChange(of: templates) { _ in
- selectedIndex = 0
- }
- .onAppear {
- selectedIndex = 0
- localMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
- switch event.keyCode {
- case 126: // Up arrow
- moveSelection(up: true)
- case 125: // Down arrow
- moveSelection(up: false)
- case 36: // Return key
- handleEnter()
- case 48: // Tab key
- handleTab()
- return nil // not forwarding the Tab Event which will replace the typed message to "\t"
- default:
- break
- }
- return event
- }
- }
- .onDisappear {
- if let monitor = localMonitor {
- NSEvent.removeMonitor(monitor)
- localMonitor = nil
- }
- }
- }
-
- private func moveSelection(up: Bool) {
- guard !templates.isEmpty else { return }
- let lowerBound = 0
- let upperBound = templates.count - 1
- let newIndex = selectedIndex + (up ? -1 : 1)
- selectedIndex = newIndex < lowerBound ? upperBound : (newIndex > upperBound ? lowerBound : newIndex)
- }
-
- private func handleEnter() {
- handleTemplateSelection()
- }
-
- private func handleTab() {
- handleTemplateSelection()
- }
-
- private func handleTemplateSelection() {
- if templates.count > 0 && selectedIndex < templates.count {
- onSelect(templates[selectedIndex])
- }
- }
-}
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/ContextUtils.swift b/Core/Sources/ConversationTab/ContextUtils.swift
index 71277a66..6a646248 100644
--- a/Core/Sources/ConversationTab/ContextUtils.swift
+++ b/Core/Sources/ConversationTab/ContextUtils.swift
@@ -2,76 +2,54 @@ import ConversationServiceProvider
import XcodeInspector
import Foundation
import Logger
-
-public let supportedFileExtensions: Set = ["swift", "m", "mm", "h", "cpp", "c", "js", "py", "rb", "java", "applescript", "scpt", "plist", "entitlements"]
-private let skipPatterns: [String] = [
- ".git",
- ".svn",
- ".hg",
- "CVS",
- ".DS_Store",
- "Thumbs.db",
- "node_modules",
- "bower_components"
-]
+import Workspace
+import SystemUtils
public struct ContextUtils {
- static func matchesPatterns(_ url: URL, patterns: [String]) -> Bool {
- let fileName = url.lastPathComponent
- for pattern in patterns {
- if fnmatch(pattern, fileName, 0) == 0 {
- return true
- }
+
+ public static func getFilesFromWorkspaceIndex(workspaceURL: URL?) -> [ConversationAttachedReference]? {
+ guard let workspaceURL = workspaceURL else { return nil }
+
+ var references: [ConversationAttachedReference]?
+
+ if let directories = WorkspaceDirectoryIndex.shared.getDirectories(for: workspaceURL) {
+ references = directories
+ .sorted { $0.url.lastPathComponent < $1.url.lastPathComponent }
+ .map { .directory($0) }
}
- return false
+
+ if let files = WorkspaceFileIndex.shared.getFiles(for: workspaceURL) {
+ references = (references ?? []) + files
+ .sorted { $0.url.lastPathComponent < $1.url.lastPathComponent }
+ .map { .file($0) }
+ }
+
+
+ return references
}
- public static func getFilesInActiveWorkspace() -> [FileReference] {
- guard let workspaceURL = XcodeInspector.shared.realtimeActiveWorkspaceURL,
- let projectURL = XcodeInspector.shared.realtimeActiveProjectURL else {
- return []
+ public static func getFilesInActiveWorkspace(workspaceURL: URL?) -> [ConversationFileReference] {
+ if let workspaceURL = workspaceURL, let info = WorkspaceFile.getWorkspaceInfo(workspaceURL: workspaceURL) {
+ return WorkspaceFile.getFilesInActiveWorkspace(workspaceURL: info.workspaceURL, workspaceRootURL: info.projectURL)
}
- do {
- let fileManager = FileManager.default
- let enumerator = fileManager.enumerator(
- at: projectURL,
- includingPropertiesForKeys: [.isRegularFileKey, .isDirectoryKey],
- options: [.skipsHiddenFiles]
- )
-
- var files: [FileReference] = []
- while let fileURL = enumerator?.nextObject() as? URL {
- // Skip items matching the specified pattern
- if matchesPatterns(fileURL, patterns: skipPatterns) {
- enumerator?.skipDescendants()
- continue
- }
-
- let resourceValues = try fileURL.resourceValues(forKeys: [.isRegularFileKey, .isDirectoryKey])
- // Handle directories if needed
- if resourceValues.isDirectory == true {
- continue
- }
-
- guard resourceValues.isRegularFile == true else { continue }
- if supportedFileExtensions.contains(fileURL.pathExtension.lowercased()) == false {
- continue
- }
-
- let relativePath = fileURL.path.replacingOccurrences(of: projectURL.path, with: "")
- let fileName = fileURL.lastPathComponent
-
- let file = FileReference(url: fileURL,
- relativePath: relativePath,
- fileName: fileName)
- files.append(file)
- }
-
- return files
- } catch {
- Logger.client.error("Failed to get files in workspace: \(error)")
+ guard let workspaceURL = XcodeInspector.shared.realtimeActiveWorkspaceURL,
+ let workspaceRootURL = XcodeInspector.shared.realtimeActiveProjectURL else {
return []
}
+
+ let files = WorkspaceFile.getFilesInActiveWorkspace(workspaceURL: workspaceURL, workspaceRootURL: workspaceRootURL)
+
+ return files
+ }
+
+ public static let workspaceReadabilityErrorMessageProvider: FileUtils.ReadabilityErrorMessageProvider = { status in
+ switch status {
+ case .readable: return nil
+ case .notFound:
+ return "Copilot can't access this workspace. It may have been removed or is temporarily unavailable."
+ case .permissionDenied:
+ return "Copilot can't access this workspace. Enable \"Files & Folders\" access in [System Settings](x-apple.systempreferences:com.apple.preference.security?Privacy_FilesAndFolders)"
+ }
}
}
diff --git a/Core/Sources/ConversationTab/Controller/DiffViewWindowController.swift b/Core/Sources/ConversationTab/Controller/DiffViewWindowController.swift
new file mode 100644
index 00000000..4a52af45
--- /dev/null
+++ b/Core/Sources/ConversationTab/Controller/DiffViewWindowController.swift
@@ -0,0 +1,160 @@
+import SwiftUI
+import ChatService
+import ComposableArchitecture
+import WebKit
+import ChatAPIService
+
+enum Style {
+ /// default diff view frame. Same as the `ChatPanel`
+ static let diffViewHeight: Double = 560
+ static let diffViewWidth: Double = 504
+}
+
+class DiffViewWindowController: NSObject, NSWindowDelegate {
+ enum DiffViewerState {
+ case shown, closed
+ }
+
+ private var diffWindow: NSWindow?
+ private var hostingView: NSHostingView?
+ private weak var chat: StoreOf?
+ public private(set) var currentFileEdit: FileEdit? = nil
+ public private(set) var diffViewerState: DiffViewerState = .closed
+
+ public init(chat: StoreOf) {
+ self.chat = chat
+ }
+
+ deinit {
+ // Break the delegate cycle
+ diffWindow?.delegate = nil
+
+ // Close and release the wi
+ diffWindow?.close()
+ diffWindow = nil
+
+ // Clear hosting view
+ hostingView = nil
+
+ // Reset state
+ currentFileEdit = nil
+ diffViewerState = .closed
+ }
+
+ @MainActor
+ func showDiffWindow(fileEdit: FileEdit) {
+ guard let chat else { return }
+
+ currentFileEdit = fileEdit
+ // Create diff view
+ let newDiffView = DiffView(chat: chat, fileEdit: fileEdit)
+
+ if let window = diffWindow, let _ = hostingView {
+ window.title = "Diff View"
+
+ let newHostingView = NSHostingView(rootView: newDiffView)
+ // Ensure the hosting view fills the window
+ newHostingView.translatesAutoresizingMaskIntoConstraints = false
+
+ self.hostingView = newHostingView
+ window.contentView = newHostingView
+
+ // Set constraints to fill the window
+ if let contentView = window.contentView {
+ newHostingView.frame = contentView.bounds
+ newHostingView.autoresizingMask = [.width, .height]
+ }
+
+ window.makeKeyAndOrderFront(nil)
+ } else {
+ let newHostingView = NSHostingView(rootView: newDiffView)
+ newHostingView.translatesAutoresizingMaskIntoConstraints = false
+ self.hostingView = newHostingView
+
+ let window = NSWindow(
+ contentRect: getDiffViewFrame(),
+ styleMask: [.titled, .closable, .miniaturizable, .resizable],
+ backing: .buffered,
+ defer: false
+ )
+
+ window.title = "Diff View"
+ window.contentView = newHostingView
+
+ // Set constraints to fill the window
+ if let contentView = window.contentView {
+ newHostingView.frame = contentView.bounds
+ newHostingView.autoresizingMask = [.width, .height]
+ }
+
+ window.center()
+ window.delegate = self
+ window.isReleasedWhenClosed = false
+
+ self.diffWindow = window
+ }
+
+ NSApp.activate(ignoringOtherApps: true)
+ diffWindow?.makeKeyAndOrderFront(nil)
+
+ diffViewerState = .shown
+ }
+
+ func windowWillClose(_ notification: Notification) {
+ if let window = notification.object as? NSWindow, window == diffWindow {
+ DispatchQueue.main.async {
+ self.diffWindow?.orderOut(nil)
+ }
+ }
+ }
+
+ @MainActor
+ func hideWindow() {
+ guard diffViewerState != .closed else { return }
+ diffWindow?.orderOut(nil)
+ diffViewerState = .closed
+ }
+
+ func getDiffViewFrame() -> NSRect {
+ guard let mainScreen = NSScreen.screens.first(where: { $0.frame.origin == .zero })
+ else {
+ /// default value
+ return .init(x: 0, y:0, width: Style.diffViewWidth, height: Style.diffViewHeight)
+ }
+
+ let visibleScreenFrame = mainScreen.visibleFrame
+ // avoid too wide
+ let width = min(Style.diffViewWidth, visibleScreenFrame.width * 0.3)
+ let height = visibleScreenFrame.height
+
+ return CGRect(x: 0, y: 0, width: width, height: height)
+ }
+
+ func windowDidResize(_ notification: Notification) {
+ if let window = notification.object as? NSWindow, window == diffWindow {
+ if let hostingView = self.hostingView,
+ let webView = findWebView(in: hostingView) {
+ let script = """
+ if (window.DiffViewer && window.DiffViewer.handleResize) {
+ window.DiffViewer.handleResize();
+ }
+ """
+ webView.evaluateJavaScript(script)
+ }
+ }
+ }
+
+ private func findWebView(in view: NSView) -> WKWebView? {
+ if let webView = view as? WKWebView {
+ return webView
+ }
+
+ for subview in view.subviews {
+ if let webView = findWebView(in: subview) {
+ return webView
+ }
+ }
+
+ return nil
+ }
+}
diff --git a/Core/Sources/ConversationTab/ConversationTab.swift b/Core/Sources/ConversationTab/ConversationTab.swift
index 0aa6026c..2884f332 100644
--- a/Core/Sources/ConversationTab/ConversationTab.swift
+++ b/Core/Sources/ConversationTab/ConversationTab.swift
@@ -8,6 +8,9 @@ import Foundation
import ChatAPIService
import Preferences
import SwiftUI
+import AppKit
+import Workspace
+import ConversationServiceProvider
/// A chat tab that provides a context aware chat bot, powered by Chat.
public class ConversationTab: ChatTab {
@@ -19,6 +22,7 @@ public class ConversationTab: ChatTab {
private var cancellable = Set()
private var observer = NSObject()
private let updateContentDebounce = DebounceRunner(duration: 0.5)
+ private var isRestored: Bool = false
// Get chat tab title. As the tab title is always "Chat" and won't be modified.
// Use the chat title as the tab title.
@@ -105,56 +109,179 @@ public class ConversationTab: ChatTab {
return [Builder(title: "New Chat", customCommand: nil)] + customCommands
}
+ // store.state is type of ChatTabInfo
+ // add the with parameters to avoiding must override the init
@MainActor
- public init(service: ChatService = ChatService.service(), store: StoreOf) {
+ public init(store: StoreOf, with chatTabInfo: ChatTabInfo? = nil) {
+ let info = chatTabInfo ?? store.state
+
+ let service = ChatService.service(for: info)
+ self.service = service
+ chat = .init(initialState: .init(workspaceURL: service.getWorkspaceURL()), reducer: { Chat(service: service) })
+ super.init(store: store)
+
+ // Start to observe changes of Chat Message
+ self.start()
+
+ // new created tab do not need restore
+ self.isRestored = true
+ }
+
+ // for restore
+ @MainActor
+ public init(service: ChatService, store: StoreOf, with chatTabInfo: ChatTabInfo) {
self.service = service
- chat = .init(initialState: .init(), reducer: { Chat(service: service) })
+ chat = .init(initialState: .init(workspaceURL: service.getWorkspaceURL()), reducer: { Chat(service: service) })
super.init(store: store)
}
+
+ deinit {
+ // Cancel all Combine subscriptions
+ cancellable.forEach { $0.cancel() }
+ cancellable.removeAll()
+
+ // Stop the debounce runner
+ Task { @MainActor [weak self] in
+ await self?.updateContentDebounce.cancel()
+ }
+
+ // Clear observer
+ observer = NSObject()
+
+ // The deallocation of ChatService will be called automatically
+ // The TCA Store (chat) handles its own cleanup automatically
+ }
+
+ @MainActor
+ public static func restoreConversation(by chatTabInfo: ChatTabInfo, store: StoreOf) -> ConversationTab {
+ let service = ChatService.service(for: chatTabInfo)
+ let tab = ConversationTab(service: service, store: store, with: chatTabInfo)
+
+ // lazy restore converstaion tab for not selected
+ if chatTabInfo.isSelected {
+ tab.restoreIfNeeded()
+ }
+
+ return tab
+ }
+
+ @MainActor
+ public func restoreIfNeeded() {
+ guard self.isRestored == false else { return }
+ // restore chat history
+ self.service.restoreIfNeeded()
+ // start observer
+ self.start()
+
+ self.isRestored = true
+ }
public func start() {
observer = .init()
cancellable = []
+
+ chat.send(.setDiffViewerController(chat: chat))
- chatTabStore.send(.updateTitle("Chat"))
-
- do {
- var lastTrigger = -1
- observer.observe { [weak self] in
- guard let self else { return }
- let trigger = chatTabStore.focusTrigger
- guard lastTrigger != trigger else { return }
- lastTrigger = trigger
- Task { @MainActor [weak self] in
- self?.chat.send(.focusOnTextField)
- }
- }
- }
+// chatTabStore.send(.updateTitle("Chat"))
- do {
- var lastTitle = ""
- observer.observe { [weak self] in
- guard let self else { return }
- let title = self.chatTabStore.state.title
- guard lastTitle != title else { return }
- lastTitle = title
- Task { @MainActor [weak self] in
- self?.chatTabStore.send(.updateTitle(title))
+// do {
+// var lastTrigger = -1
+// observer.observe { [weak self] in
+// guard let self else { return }
+// let trigger = chatTabStore.focusTrigger
+// guard lastTrigger != trigger else { return }
+// lastTrigger = trigger
+// Task { @MainActor [weak self] in
+// self?.chat.send(.focusOnTextField)
+// }
+// }
+// }
+
+// do {
+// var lastTitle = ""
+// observer.observe { [weak self] in
+// guard let self else { return }
+// let title = self.chatTabStore.state.title
+// guard lastTitle != title else { return }
+// lastTitle = title
+// Task { @MainActor [weak self] in
+// self?.chatTabStore.send(.updateTitle(title))
+// }
+// }
+// }
+
+ var lastIsReceivingMessage = false
+
+ observer.observe { [weak self] in
+ guard let self else { return }
+// let history = chat.history
+// _ = chat.title
+// _ = chat.isReceivingMessage
+
+ // As the observer won't check the state if changed, we need to check it manually.
+ // Currently, only receciving message is used. If more states are needed, we can add them here.
+ let currentIsReceivingMessage = chat.isReceivingMessage
+
+ // Only trigger when isReceivingMessage changes
+ if lastIsReceivingMessage != currentIsReceivingMessage {
+ lastIsReceivingMessage = currentIsReceivingMessage
+ Task {
+ await self.updateContentDebounce.debounce { @MainActor [weak self] in
+ guard let self else { return }
+ self.chatTabStore.send(.tabContentUpdated)
+
+ if let suggestedTitle = chat.history.last?.suggestedTitle {
+ self.chatTabStore.send(.updateTitle(suggestedTitle))
+ }
+
+ if let CLSConversationID = self.service.conversationId,
+ self.chatTabStore.CLSConversationID != CLSConversationID
+ {
+ self.chatTabStore.send(.setCLSConversationID(CLSConversationID))
+ }
+ }
}
}
}
+ }
+
+ public func handlePasteEvent() -> Bool {
+ let pasteboard = NSPasteboard.general
+ if let urls = pasteboard.readObjects(forClasses: [NSURL.self], options: nil) as? [URL], !urls.isEmpty {
+ for url in urls {
+ // Check if it's a remote URL (http/https)
+ if url.scheme == "http" || url.scheme == "https" {
+ return false
+ }
- observer.observe { [weak self] in
- guard let self else { return }
- _ = chat.history
- _ = chat.title
- _ = chat.isReceivingMessage
- Task {
- await self.updateContentDebounce.debounce { @MainActor [weak self] in
- self?.chatTabStore.send(.tabContentUpdated)
+ if let isValidFile = try? WorkspaceFile.isValidFile(url), isValidFile {
+ DispatchQueue.main.async {
+ let fileReference = ConversationFileReference(url: url, isCurrentEditor: false)
+ self.chat.send(.addReference(.file(fileReference)))
+ }
+ } else if let data = try? Data(contentsOf: url),
+ ["png", "jpeg", "jpg", "bmp", "gif", "tiff", "tif", "webp"].contains(url.pathExtension.lowercased()) {
+ DispatchQueue.main.async {
+ self.chat.send(.addSelectedImage(ImageReference(data: data, fileUrl: url)))
+ }
}
}
+ } else if let data = pasteboard.data(forType: .png) {
+ chat.send(.addSelectedImage(ImageReference(data: data, source: .pasted)))
+ } else if let tiffData = pasteboard.data(forType: .tiff),
+ let imageRep = NSBitmapImageRep(data: tiffData),
+ let pngData = imageRep.representation(using: .png, properties: [:]) {
+ chat.send(.addSelectedImage(ImageReference(data: pngData, source: .pasted)))
+ } else {
+ return false
}
+
+ return true
+ }
+
+ public func updateChatTabInfo(_ tabInfo: ChatTabInfo) {
+ // Sync tabInfo for service
+ service.updateChatTabInfo(tabInfo)
}
}
diff --git a/Core/Sources/ConversationTab/DiffViews/DiffView.swift b/Core/Sources/ConversationTab/DiffViews/DiffView.swift
new file mode 100644
index 00000000..ee66ec8b
--- /dev/null
+++ b/Core/Sources/ConversationTab/DiffViews/DiffView.swift
@@ -0,0 +1,97 @@
+import SwiftUI
+import WebKit
+import ComposableArchitecture
+import Logger
+import ConversationServiceProvider
+import ChatService
+import ChatTab
+import ChatAPIService
+
+extension FileEdit {
+ var originalContentByStatus: String {
+ return status == .kept ? modifiedContent : originalContent
+ }
+
+ var modifiedContentByStatus: String {
+ return status == .undone ? originalContent : modifiedContent
+ }
+}
+
+struct DiffView: View {
+ @Perception.Bindable var chat: StoreOf
+ @State public var fileEdit: FileEdit
+
+ var body: some View {
+ WithPerceptionTracking {
+ DiffWebView(
+ chat: chat,
+ fileEdit: fileEdit
+ )
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .edgesIgnoringSafeArea(.all)
+ }
+ }
+}
+
+// preview
+struct DiffView_Previews: PreviewProvider {
+ static var oldText = """
+ import Foundation
+
+ func calculateTotal(items: [Double]) -> Double {
+ var sum = 0.0
+ for item in items {
+ sum += item
+ }
+ return sum
+ }
+
+ func main() {
+ let prices = [10.5, 20.0, 15.75]
+ let total = calculateTotal(items: prices)
+ print("Total: \\(total)")
+ }
+
+ main()
+ """
+
+ static var newText = """
+ import Foundation
+
+ func calculateTotal(items: [Double], applyDiscount: Bool = false) -> Double {
+ var sum = 0.0
+ for item in items {
+ sum += item
+ }
+
+ // Apply 10% discount if requested
+ if applyDiscount {
+ sum *= 0.9
+ }
+
+ return sum
+ }
+
+ func main() {
+ let prices = [10.5, 20.0, 15.75, 5.0]
+ let total = calculateTotal(items: prices)
+ let discountedTotal = calculateTotal(items: prices, applyDiscount: true)
+
+ print("Total: \\(total)")
+ print("With discount: \\(discountedTotal)")
+ }
+
+ main()
+ """
+ static let chatTabInfo = ChatTabInfo(id: "", workspacePath: "path", username: "name")
+ static var previews: some View {
+ DiffView(
+ chat: .init(
+ initialState: .init(history: ChatPanel_Preview.history, isReceivingMessage: true),
+ reducer: { Chat(service: ChatService.service(for: chatTabInfo)) }
+ ),
+ fileEdit: .init(fileURL: URL(fileURLWithPath: "file:///f1.swift"), originalContent: "test", modifiedContent: "abc", toolName: ToolName.insertEditIntoFile)
+ )
+ .frame(width: 800, height: 600)
+ }
+}
diff --git a/Core/Sources/ConversationTab/DiffViews/DiffWebView.swift b/Core/Sources/ConversationTab/DiffViews/DiffWebView.swift
new file mode 100644
index 00000000..36c952a5
--- /dev/null
+++ b/Core/Sources/ConversationTab/DiffViews/DiffWebView.swift
@@ -0,0 +1,185 @@
+import ComposableArchitecture
+import ChatService
+import SwiftUI
+import WebKit
+import Logger
+import ChatAPIService
+
+struct DiffWebView: NSViewRepresentable {
+ @Perception.Bindable var chat: StoreOf
+ var fileEdit: FileEdit
+
+ init(chat: StoreOf, fileEdit: FileEdit) {
+ self.chat = chat
+ self.fileEdit = fileEdit
+ }
+
+ func makeNSView(context: Context) -> WKWebView {
+ let configuration = WKWebViewConfiguration()
+ let userContentController = WKUserContentController()
+
+ #if DEBUG
+ let scriptSource = """
+ function captureLog(msg) { window.webkit.messageHandlers.logging.postMessage(Array.prototype.slice.call(arguments)); }
+ console.log = captureLog;
+ console.error = captureLog;
+ console.warn = captureLog;
+ console.info = captureLog;
+ """
+ let script = WKUserScript(source: scriptSource, injectionTime: .atDocumentStart, forMainFrameOnly: true)
+ userContentController.addUserScript(script)
+ userContentController.add(context.coordinator, name: "logging")
+ #endif
+
+ userContentController.add(context.coordinator, name: "swiftHandler")
+ configuration.userContentController = userContentController
+
+ let webView = WKWebView(frame: .zero, configuration: configuration)
+ webView.navigationDelegate = context.coordinator
+ #if DEBUG
+ webView.configuration.preferences.setValue(true, forKey: "developerExtrasEnabled")
+ #endif
+
+ // Configure WebView
+ webView.wantsLayer = true
+ webView.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor
+ webView.layer?.borderWidth = 1
+
+ // Make the webview auto-resize with its container
+ webView.autoresizingMask = [.width, .height]
+ webView.translatesAutoresizingMaskIntoConstraints = true
+
+ // Notify the webview of resize events explicitly
+ let resizeNotificationScript = WKUserScript(
+ source: """
+ window.addEventListener('resize', function() {
+ if (window.DiffViewer && window.DiffViewer.handleResize) {
+ window.DiffViewer.handleResize();
+ }
+ });
+ """,
+ injectionTime: .atDocumentEnd,
+ forMainFrameOnly: true
+ )
+ webView.configuration.userContentController.addUserScript(resizeNotificationScript)
+
+ /// Load web asset resources
+ let bundleBaseURL = Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/webViewDist/diffView")
+ let htmlFileURL = bundleBaseURL.appendingPathComponent("diffView.html")
+ webView.loadFileURL(htmlFileURL, allowingReadAccessTo: bundleBaseURL)
+
+ return webView
+ }
+
+ func updateNSView(_ webView: WKWebView, context: Context) {
+ if context.coordinator.shouldUpdate(fileEdit) {
+ // Update content via JavaScript API
+ let script = """
+ if (typeof window.DiffViewer !== 'undefined') {
+ window.DiffViewer.update(
+ `\(escapeJSString(fileEdit.originalContentByStatus))`,
+ `\(escapeJSString(fileEdit.modifiedContentByStatus))`,
+ `\(escapeJSString(fileEdit.fileURL.absoluteString))`,
+ `\(fileEdit.status.rawValue)`
+ );
+ } else {
+ console.error("DiffViewer is not defined in update");
+ }
+ """
+ webView.evaluateJavaScript(script)
+ }
+ }
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator(self)
+ }
+
+ class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler {
+ var parent: DiffWebView
+ private var fileEdit: FileEdit
+
+ init(_ parent: DiffWebView) {
+ self.parent = parent
+ self.fileEdit = parent.fileEdit
+ }
+
+ func shouldUpdate(_ fileEdit: FileEdit) -> Bool {
+ let shouldUpdate = self.fileEdit != fileEdit
+
+ if shouldUpdate {
+ self.fileEdit = fileEdit
+ }
+
+ return shouldUpdate
+ }
+
+ func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
+ #if DEBUG
+ if message.name == "logging" {
+ if let logs = message.body as? [Any] {
+ let logString = logs.map { "\($0)" }.joined(separator: " ")
+ Logger.client.info("WebView console: \(logString)")
+ }
+ return
+ }
+ #endif
+
+ guard message.name == "swiftHandler",
+ let body = message.body as? [String: Any],
+ let event = body["event"] as? String,
+ let data = body["data"] as? [String: String],
+ let filePath = data["filePath"],
+ let fileURL = URL(string: filePath)
+ else { return }
+
+ switch event {
+ case "undoButtonClicked":
+ self.parent.chat.send(.undoEdits(fileURLs: [fileURL]))
+ case "keepButtonClicked":
+ self.parent.chat.send(.keepEdits(fileURLs: [fileURL]))
+ default:
+ break
+ }
+ }
+
+ // Initialize content when the page has finished loading
+ func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
+ let script = """
+ if (typeof window.DiffViewer !== 'undefined') {
+ window.DiffViewer.init(
+ `\(escapeJSString(fileEdit.originalContentByStatus))`,
+ `\(escapeJSString(fileEdit.modifiedContentByStatus))`,
+ `\(escapeJSString(fileEdit.fileURL.absoluteString))`,
+ `\(fileEdit.status.rawValue)`
+ );
+ } else {
+ console.error("DiffViewer is not defined on page load");
+ }
+ """
+ webView.evaluateJavaScript(script) { result, error in
+ if let error = error {
+ Logger.client.error("Error evaluating JavaScript: \(error)")
+ }
+ }
+ }
+
+ // Handle navigation errors
+ func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
+ Logger.client.error("WebView navigation failed: \(error)")
+ }
+
+ func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
+ Logger.client.error("WebView provisional navigation failed: \(error)")
+ }
+ }
+}
+
+func escapeJSString(_ string: String) -> String {
+ return string
+ .replacingOccurrences(of: "\\", with: "\\\\")
+ .replacingOccurrences(of: "`", with: "\\`")
+ .replacingOccurrences(of: "\n", with: "\\n")
+ .replacingOccurrences(of: "\r", with: "\\r")
+ .replacingOccurrences(of: "\"", with: "\\\"")
+ .replacingOccurrences(of: "$", with: "\\$")
+}
diff --git a/Core/Sources/ConversationTab/Features/ConversationCodeReviewFeature.swift b/Core/Sources/ConversationTab/Features/ConversationCodeReviewFeature.swift
new file mode 100644
index 00000000..d55acc6d
--- /dev/null
+++ b/Core/Sources/ConversationTab/Features/ConversationCodeReviewFeature.swift
@@ -0,0 +1,92 @@
+import ComposableArchitecture
+import ChatService
+import Foundation
+import ConversationServiceProvider
+import GitHelper
+import LanguageServerProtocol
+import Terminal
+import Combine
+
+@MainActor
+public class CodeReviewStateService: ObservableObject {
+ public static let shared = CodeReviewStateService()
+
+ public let fileClickedEvent = PassthroughSubject()
+
+ private init() { }
+
+ func notifyFileClicked() {
+ fileClickedEvent.send()
+ }
+}
+
+@Reducer
+public struct ConversationCodeReviewFeature {
+ @ObservableState
+ public struct State: Equatable {
+
+ public init() { }
+ }
+
+ public enum Action: Equatable {
+ case request(GitDiffGroup)
+ case accept(id: String, selectedFiles: [DocumentUri])
+ case cancel(id: String)
+
+ case onFileClicked(URL, Int)
+ }
+
+ public let service: ChatService
+
+ public var body: some ReducerOf {
+ Reduce { state, action in
+ switch action {
+ case .request(let group):
+
+ return .run { _ in
+ try await service.requestCodeReview(group)
+ }
+
+ case let .accept(id, selectedFileUris):
+
+ return .run { _ in
+ await service.acceptCodeReview(id, selectedFileUris: selectedFileUris)
+ }
+
+ case .cancel(let id):
+
+ return .run { _ in
+ await service.cancelCodeReview(id)
+ }
+
+ // lineNumber: 0-based
+ case .onFileClicked(let fileURL, let lineNumber):
+
+ return .run { _ in
+ if FileManager.default.fileExists(atPath: fileURL.path) {
+ let terminal = Terminal()
+ do {
+ _ = try await terminal.runCommand(
+ "/bin/bash",
+ arguments: [
+ "-c",
+ "xed -l \(lineNumber+1) \"${TARGET_REVIEW_FILE}\""
+ ],
+ environment: [
+ "TARGET_REVIEW_FILE": fileURL.path
+ ]
+ )
+ } catch {
+ print(error)
+ }
+ }
+
+ Task { @MainActor in
+ CodeReviewStateService.shared.notifyFileClicked()
+ }
+ }
+
+ }
+ }
+ }
+}
diff --git a/Core/Sources/ConversationTab/FilePicker.swift b/Core/Sources/ConversationTab/FilePicker.swift
new file mode 100644
index 00000000..284c52ec
--- /dev/null
+++ b/Core/Sources/ConversationTab/FilePicker.swift
@@ -0,0 +1,242 @@
+import ComposableArchitecture
+import ConversationServiceProvider
+import SharedUIComponents
+import SwiftUI
+import SystemUtils
+
+public struct FilePicker: View {
+ @Binding var allFiles: [ConversationAttachedReference]?
+ let workspaceURL: URL?
+ var onSubmit: (_ file: ConversationAttachedReference) -> Void
+ var onExit: () -> Void
+ @FocusState private var isSearchBarFocused: Bool
+ @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]? {
+ guard let allFiles else { return nil }
+
+ let directories = allFiles
+ .filter { $0.isDirectory }
+ .filter {
+ guard case let .directory(directory) = $0 else {
+ return false
+ }
+
+ return directory.depth == 1
+ }
+
+ let files = allFiles.filter { !$0.isDirectory }
+
+ return directories + files
+ }
+
+ private var filteredReferences: [ConversationAttachedReference]? {
+ if searchText.isEmpty {
+ return defaultReferencesForDisplay
+ }
+
+ return allFiles?.filter { ref in
+ ref.url.lastPathComponent.localizedCaseInsensitiveContains(searchText)
+ }
+ }
+
+ private static let defaultEmptyStateText = "No results found."
+ private static let isIndexingStateText = "Indexing files, try later..."
+
+ private var emptyStateAttributedString: AttributedString? {
+ var message = allFiles == nil ? FilePicker.isIndexingStateText : FilePicker.defaultEmptyStateText
+ if let workspaceURL = workspaceURL {
+ let status = FileUtils.checkFileReadability(at: workspaceURL.path)
+ if let errorMessage = status.errorMessage(using: ContextUtils.workspaceReadabilityErrorMessageProvider) {
+ message = errorMessage
+ }
+ }
+
+ return try? AttributedString(markdown: message)
+ }
+
+ private var emptyStateView: some View {
+ Group {
+ if let attributedString = emptyStateAttributedString {
+ Text(attributedString)
+ } else {
+ Text(FilePicker.defaultEmptyStateText)
+ }
+ }
+ }
+
+ public var body: some View {
+ WithPerceptionTracking {
+ VStack(spacing: 8) {
+ HStack {
+ Image(systemName: "magnifyingglass")
+ .foregroundColor(.secondary)
+
+ TextField("Search files...", text: $searchText)
+ .scaledFont(.body)
+ .textFieldStyle(PlainTextFieldStyle())
+ .foregroundColor(searchText.isEmpty ? Color(nsColor: .placeholderTextColor) : Color(nsColor: .textColor))
+ .focused($isSearchBarFocused)
+ .onChange(of: searchText) { newValue in
+ selectedId = 0
+ }
+ .onAppear() {
+ isSearchBarFocused = true
+ }
+
+ Button(action: {
+ withAnimation {
+ onExit()
+ }
+ }) {
+ Image(systemName: "xmark.circle.fill")
+ .scaledFont(.body)
+ .foregroundColor(.secondary)
+ }
+ .buttonStyle(HoverButtonStyle())
+ .help("Close")
+ }
+ .padding(8)
+ .background(
+ RoundedRectangle(cornerRadius: 10)
+ .fill(Color.gray.opacity(0.1))
+ )
+ .cornerRadius(6)
+ .padding(.horizontal, 4)
+ .padding(.top, 4)
+
+ ScrollViewReader { proxy in
+ ScrollView {
+ LazyVStack(alignment: .leading, spacing: 4) {
+ if allFiles == nil || filteredReferences?.isEmpty == true {
+ emptyStateView
+ .foregroundColor(.secondary)
+ .padding(.leading, 4)
+ .padding(.vertical, 4)
+ } else {
+ ForEach(Array((filteredReferences ?? []).enumerated()), id: \.element) { index, ref in
+ FileRowView(ref: ref, id: index, selectedId: $selectedId)
+ .contentShape(Rectangle())
+ .onTapGesture {
+ onSubmit(ref)
+ selectedId = index
+ isSearchBarFocused = true
+ }
+ .id(index)
+ }
+ }
+ }
+ .id(filteredReferences?.hashValue)
+ }
+ .frame(maxHeight: 200)
+ .padding(.horizontal, 4)
+ .padding(.bottom, 4)
+ .onAppear {
+ localMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
+ if !isSearchBarFocused { // if file search bar is not focused, ignore the event
+ return event
+ }
+
+ switch event.keyCode {
+ case 126: // Up arrow
+ moveSelection(up: true, proxy: proxy)
+ return nil
+ case 125: // Down arrow
+ moveSelection(up: false, proxy: proxy)
+ return nil
+ case 36: // Return key
+ handleEnter()
+ return nil
+ case 53: // Esc key
+ withAnimation {
+ onExit()
+ }
+ return nil
+ default:
+ break
+ }
+ return event
+ }
+ }
+ .onDisappear {
+ if let monitor = localMonitor {
+ NSEvent.removeMonitor(monitor)
+ localMonitor = nil
+ }
+ }
+ }
+ }
+ .fixedSize(horizontal: false, vertical: true)
+ .cornerRadius(6)
+ .overlay(
+ RoundedRectangle(cornerRadius: 8)
+ .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
+ )
+ }
+ }
+
+ private func moveSelection(up: Bool, proxy: ScrollViewProxy) {
+ guard let refs = filteredReferences, !refs.isEmpty else { return }
+ let nextId = selectedId + (up ? -1 : 1)
+ selectedId = max(0, min(nextId, refs.count - 1))
+ proxy.scrollTo(selectedId, anchor: .bottom)
+ }
+
+ private func handleEnter() {
+ guard let refs = filteredReferences, !refs.isEmpty && selectedId < refs.count else {
+ return
+ }
+
+ onSubmit(refs[selectedId])
+ }
+}
+
+struct FileRowView: View {
+ @State private var isHovered = false
+ let ref: ConversationAttachedReference
+ let id: Int
+ @Binding var selectedId: Int
+
+ var body: some View {
+ WithPerceptionTracking {
+ HStack(alignment: .center) {
+ drawFileIcon(ref.url, isDirectory: ref.isDirectory)
+ .scaledToFit()
+ .scaledFrame(width: 16, height: 16)
+ .hoverSecondaryForeground(isHovered: selectedId == id)
+ .padding(.leading, 4)
+
+ HStack(spacing: 4) {
+ Text(ref.displayName)
+ .scaledFont(.body)
+ .hoverPrimaryForeground(isHovered: selectedId == id)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ .layoutPriority(1)
+
+ Text(ref.relativePath)
+ .scaledFont(.caption)
+ .hoverSecondaryForeground(isHovered: selectedId == id)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ // Ensure relative path remains visible even when display name is very long
+ .frame(minWidth: 80, alignment: .leading)
+ }
+
+ Spacer()
+ }
+ .padding(.vertical, 4)
+ .hoverRadiusBackground(isHovered: isHovered || selectedId == id,
+ hoverColor: (selectedId == id ? nil : Color.gray.opacity(0.1)),
+ cornerRadius: 6)
+ .onHover(perform: { hovering in
+ isHovered = hovering
+ })
+ .help(ref.url.path)
+ }
+ }
+}
diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModeAndModelPickerPicker.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModeAndModelPickerPicker.swift
new file mode 100644
index 00000000..e8302f4e
--- /dev/null
+++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModeAndModelPickerPicker.swift
@@ -0,0 +1,357 @@
+import SwiftUI
+import ChatService
+import Persist
+import ComposableArchitecture
+import GitHubCopilotService
+import Combine
+import HostAppActivator
+import SharedUIComponents
+import ConversationServiceProvider
+
+struct ModeAndModelPicker: View {
+ let projectRootURL: URL?
+ @Binding var selectedAgent: ConversationMode
+
+ @State private var selectedModel: LLMModel?
+ @ObservedObject private var modelManager = CopilotModelManagerObservable.shared
+ static var lastRefreshModelsTime: Date = .init(timeIntervalSince1970: 0)
+
+ @State private var chatMode = "Ask"
+
+ // 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 attributes: [NSAttributedString.Key: NSFont] = ModelMenuItemFormatter.attributes
+
+ 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)
+ }
+
+ var copilotModels: [LLMModel] {
+ AppState.shared.isAgentModeEnabled() ?
+ modelManager.availableAgentModels : modelManager.availableChatModels
+ }
+
+ var byokModels: [LLMModel] {
+ AppState.shared.isAgentModeEnabled() ?
+ modelManager.availableAgentBYOKModels : modelManager.availableChatBYOKModels
+ }
+
+ var defaultModel: LLMModel? {
+ AppState.shared.isAgentModeEnabled() ? modelManager.defaultAgentModel : modelManager.defaultChatModel
+ }
+
+ // Get the current cache based on scope
+ var currentCache: ScopeCache {
+ AppState.shared.isAgentModeEnabled() ? agentScopeCache : askScopeCache
+ }
+
+ // Update cache for specific scope only if models changed
+ func updateModelCacheIfNeeded(for scope: PromptTemplateScope) {
+ 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 {
+ guard agentScopeCache.lastModelsHash != modelsHash else { return }
+ agentScopeCache = buildCache(for: currentModels, currentHash: modelsHash)
+ } else {
+ guard askScopeCache.lastModelsHash != modelsHash else { return }
+ askScopeCache = buildCache(for: currentModels, currentHash: modelsHash)
+ }
+ }
+
+ // Build cache for given models
+ private func buildCache(for models: [LLMModel], currentHash: Int) -> ScopeCache {
+ var newCache: [String: String] = [:]
+ var maxWidth: CGFloat = 0
+
+ for model in models {
+ 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 + ModelMenuItemFormatter.minimumPaddingWidth + multiplierWidth
+ maxWidth = max(maxWidth, totalWidth)
+ }
+
+ if maxWidth == 0, let selectedModel = selectedModel {
+ maxWidth = (selectedModel.displayName ?? selectedModel.modelName).size(withAttributes: attributes).width
+ }
+
+ return ScopeCache(
+ modelMultiplierCache: newCache,
+ cachedMaxWidth: maxWidth,
+ lastModelsHash: currentHash
+ )
+ }
+
+ func updateCurrentModel() {
+ let currentModel = AppState.shared.getSelectedModel()
+ var allAvailableModels = copilotModels
+ if isBYOKFFEnabled {
+ allAvailableModels += byokModels
+ }
+
+ // 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 freshModel == nil && currentModel != nil {
+ // Switch to default model if current model is not available
+ if let fallbackModel = defaultModel {
+ AppState.shared.setSelectedModel(fallbackModel)
+ selectedModel = fallbackModel
+ } else if let firstAvailable = allAvailableModels.first {
+ // If no default model, use first available
+ AppState.shared.setSelectedModel(firstAvailable)
+ selectedModel = firstAvailable
+ } else {
+ selectedModel = nil
+ }
+ } else {
+ if let fresh = freshModel, let current = currentModel,
+ fresh.supportsReasoningEffortLevel != current.supportsReasoningEffortLevel
+ || fresh.reasoningEfforts != current.reasoningEfforts {
+ AppState.shared.setSelectedModel(fresh)
+ }
+ selectedModel = freshModel ?? defaultModel
+ }
+ }
+
+ func updateAgentPicker() {
+ self.chatMode = AppState.shared.getSelectedChatMode()
+ }
+
+ 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)
+ if let defaultModel = defaultModel {
+ AppState.shared.setSelectedModel(defaultModel)
+ } else {
+ AppState.shared.setSelectedModel(newModeModels[0])
+ }
+ }
+ }
+
+ self.updateCurrentModel()
+ updateModelCacheIfNeeded(for: scope)
+ }
+
+ // Parse model string in format "{Model DisplayName} ({providerName or copilot})"
+ // If no parentheses, defaults to Copilot model
+ private func parseModelString(_ modelString: String, from availableModels: [LLMModel]) -> LLMModel? {
+ var displayName: String
+ var isCopilotModel: Bool
+ var provider: String = ""
+
+ // Extract display name and provider from the format: "DisplayName (provider)"
+ if let openParenIndex = modelString.lastIndex(of: "("),
+ let closeParenIndex = modelString.lastIndex(of: ")"),
+ openParenIndex < closeParenIndex {
+
+ let displayNameEndIndex = modelString.index(before: openParenIndex)
+ displayName = String(modelString[.. 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/ModeAndModelPicker/ModePicker/ModeButton.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/ModeButton.swift
new file mode 100644
index 00000000..7964d448
--- /dev/null
+++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModePicker/ModeButton.swift
@@ -0,0 +1,32 @@
+import SwiftUI
+import SharedUIComponents
+
+public struct ModeButton: View {
+ let title: String
+ let isSelected: Bool
+ let activeBackground: Color
+ let activeTextColor: Color
+ let inactiveTextColor: Color
+ let action: () -> Void
+
+ public var body: some View {
+ Button(action: action) {
+ Text(title)
+ .scaledFont(size: 12)
+ .scaledPadding(.horizontal, 6)
+ .scaledPadding(.vertical, 2)
+ .frame(maxHeight: .infinity, alignment: .center)
+ .background(isSelected ? activeBackground : Color.clear)
+ .foregroundColor(isSelected ? activeTextColor : inactiveTextColor)
+ .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(
+ RoundedRectangle(cornerRadius: 5)
+ .inset(by: -0.25)
+ .stroke(.black.opacity(0.02), lineWidth: 0.5)
+ )
+ }
+ .buttonStyle(PlainButtonStyle())
+ }
+}
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/Styles.swift b/Core/Sources/ConversationTab/Styles.swift
index a4b5ddf1..eca980d5 100644
--- a/Core/Sources/ConversationTab/Styles.swift
+++ b/Core/Sources/ConversationTab/Styles.swift
@@ -35,6 +35,8 @@ extension NSAppearance {
extension View {
var messageBubbleCornerRadius: Double { 8 }
+ var hoverableImageCornerRadius: Double { 4 }
+ var inputAreaTextEditorCornerRadius: Double { 12 }
func codeBlockLabelStyle() -> some View {
relativeLineSpacing(.em(0.225))
@@ -51,7 +53,7 @@ extension View {
_ configuration: CodeBlockConfiguration,
backgroundColor: Color,
labelColor: Color,
- insertAction: (() -> Void)? = nil
+ context: MarkdownActionProvider? = nil
) -> some View {
background(backgroundColor)
.clipShape(RoundedRectangle(cornerRadius: 6))
@@ -70,9 +72,11 @@ extension View {
NSPasteboard.general.setString(configuration.content, forType: .string)
}
- InsertButton {
- if let insertAction = insertAction {
- insertAction()
+ if let context = context, context.supportInsert {
+ InsertButton {
+ if let onInsert = context.onInsert {
+ onInsert(configuration.content)
+ }
}
}
}
@@ -180,9 +184,33 @@ 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(
+ Color(nsColor: .windowBackgroundColor).opacity(0.5)
+ )
+ .cornerRadius(isCurrentEditor ? 99 : r)
+ .overlay(
+ RoundedRectangle(cornerRadius: isCurrentEditor ? 99 : r)
+ .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
+ )
}
}
+// MARK: - Code Review Background Styles
+
+struct CodeReviewCardBackground: View {
+ var body: some View {
+ RoundedRectangle(cornerRadius: 12)
+ .stroke(.black.opacity(0.17), lineWidth: 1)
+ .background(Color.gray.opacity(0.05))
+ }
+}
+
+struct CodeReviewHeaderBackground: View {
+ var body: some View {
+ 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
new file mode 100644
index 00000000..7f6725a5
--- /dev/null
+++ b/Core/Sources/ConversationTab/TerminalViews/RunInTerminalToolView.swift
@@ -0,0 +1,359 @@
+import ChatService
+import ComposableArchitecture
+import ConversationServiceProvider
+import GitHubCopilotService
+import SharedUIComponents
+import SwiftUI
+import Terminal
+import XcodeInspector
+
+struct RunInTerminalToolView: View {
+ let tool: AgentToolCall
+ let command: String?
+ let explanation: String?
+ let isBackground: Bool?
+ let chat: StoreOf
+ private var title: String = "Run command in terminal"
+
+ @AppStorage(\.codeBackgroundColorLight) var codeBackgroundColorLight
+ @AppStorage(\.codeForegroundColorLight) var codeForegroundColorLight
+ @AppStorage(\.codeBackgroundColorDark) var codeBackgroundColorDark
+ @AppStorage(\.codeForegroundColorDark) var codeForegroundColorDark
+ @AppStorage(\.chatFontSize) var chatFontSize
+ @Environment(\.colorScheme) var colorScheme
+
+ init(tool: AgentToolCall, chat: StoreOf) {
+ self.tool = tool
+ self.chat = chat
+
+ 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
+ self.title = (isBackground != nil && isBackground!) ? "Run command in background terminal" : "Run command in terminal"
+ } else {
+ self.command = nil
+ self.explanation = nil
+ self.isBackground = nil
+ }
+ }
+
+ var terminalSession: TerminalSession? {
+ return TerminalSessionManager.shared.getSession(for: tool.id)
+ }
+
+ 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 body: some View {
+ WithPerceptionTracking {
+ if tool.status == .waitForConfirmation || terminalSession != nil {
+ VStack {
+ HStack {
+ Image("Terminal")
+ .resizable()
+ .scaledToFit()
+ .scaledFrame(width: 16, height: 16)
+
+ Text(self.title)
+ .scaledFont(size: chatFontSize, weight: .semibold)
+ .foregroundStyle(.primary)
+ .background(Color.clear)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+
+ toolView
+ }
+ .scaledPadding(8)
+ .cornerRadius(8)
+ .overlay(
+ RoundedRectangle(cornerRadius: 8)
+ .stroke(Color.gray.opacity(0.2), lineWidth: 1)
+ )
+ } else {
+ toolView
+ }
+ }
+ }
+
+ var codeBackgroundColor: Color {
+ if colorScheme == .light, let color = codeBackgroundColorLight.value {
+ return color.swiftUIColor
+ } else if let color = codeBackgroundColorDark.value {
+ return color.swiftUIColor
+ }
+ return Color(nsColor: .textBackgroundColor).opacity(0.7)
+ }
+
+ var codeForegroundColor: Color {
+ if colorScheme == .light, let color = codeForegroundColorLight.value {
+ return color.swiftUIColor
+ } else if let color = codeForegroundColorDark.value {
+ return color.swiftUIColor
+ }
+ return Color(nsColor: .textColor)
+ }
+
+ var toolView: some View {
+ WithPerceptionTracking {
+ VStack {
+ if command != nil {
+ HStack(spacing: 4) {
+ statusIcon
+ .scaledFrame(width: 16, height: 16)
+
+ Text(command!)
+ .lineLimit(nil)
+ .textSelection(.enabled)
+ .scaledFont(size: chatFontSize, design: .monospaced)
+ .scaledPadding(8)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .fixedSize(horizontal: false, vertical: true)
+ .foregroundStyle(codeForegroundColor)
+ .background(codeBackgroundColor)
+ .clipShape(RoundedRectangle(cornerRadius: 6))
+ .overlay {
+ RoundedRectangle(cornerRadius: 6).stroke(Color.primary.opacity(0.05), lineWidth: 1)
+ }
+ }
+ } else {
+ Text("Invalid parameter in the toolcall for runInTerminal")
+ }
+
+ if let terminalSession = terminalSession {
+ XTermView(
+ terminalSession: terminalSession,
+ onTerminalInput: terminalSession.handleTerminalInput
+ )
+ .scaledFrame(minHeight: 200, maxHeight: 400)
+ } else if tool.status == .waitForConfirmation {
+ ThemedMarkdownText(text: explanation ?? "", chat: chat)
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ HStack {
+ Button(action: {
+ chat.send(.toolCallCancelled(tool.id))
+ }) {
+ Text("Skip")
+ .scaledFont(.body)
+ }
+
+ if FeatureFlagNotifierImpl.shared.featureFlags.agentModeAutoApproval &&
+ CopilotPolicyNotifierImpl.shared.copilotPolicy.agentModeAutoApprovalEnabled,
+ let command, !command.isEmpty {
+ SplitButton(
+ title: "Allow",
+ isDisabled: false,
+ primaryAction: {
+ chat.send(.toolCallAccepted(tool.id))
+ },
+ menuItems: terminalMenuItems(command: command),
+ style: .prominent
+ )
+ } else {
+ Button(action: {
+ chat.send(.toolCallAccepted(tool.id))
+ }) {
+ Text("Allow")
+ .scaledFont(.body)
+ }
+ .buttonStyle(BorderedProminentButtonStyle())
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .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/TerminalViews/XTermView.swift b/Core/Sources/ConversationTab/TerminalViews/XTermView.swift
new file mode 100644
index 00000000..23e1fbd0
--- /dev/null
+++ b/Core/Sources/ConversationTab/TerminalViews/XTermView.swift
@@ -0,0 +1,100 @@
+import SwiftUI
+import Logger
+import WebKit
+import Terminal
+
+struct XTermView: NSViewRepresentable {
+ @ObservedObject var terminalSession: TerminalSession
+ var onTerminalInput: (String) -> Void
+
+ var terminalOutput: String {
+ terminalSession.terminalOutput
+ }
+
+ func makeNSView(context: Context) -> WKWebView {
+ let webpagePrefs = WKWebpagePreferences()
+ webpagePrefs.allowsContentJavaScript = true
+ let preferences = WKWebViewConfiguration()
+ preferences.defaultWebpagePreferences = webpagePrefs
+ preferences.userContentController.add(context.coordinator, name: "terminalInput")
+
+ let webView = WKWebView(frame: .zero, configuration: preferences)
+ webView.navigationDelegate = context.coordinator
+ #if DEBUG
+ webView.configuration.preferences.setValue(true, forKey: "developerExtrasEnabled")
+ #endif
+
+ // Load the terminal bundle resources
+ let terminalBundleBaseURL = Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/webViewDist/terminal")
+ let htmlFileURL = terminalBundleBaseURL.appendingPathComponent("terminal.html")
+ webView.loadFileURL(htmlFileURL, allowingReadAccessTo: terminalBundleBaseURL)
+ return webView
+ }
+
+ func updateNSView(_ webView: WKWebView, context: Context) {
+ // When terminalOutput changes, send the new data to the terminal
+ if context.coordinator.lastOutput != terminalOutput {
+ let newOutput = terminalOutput.suffix(from:
+ terminalOutput.index(terminalOutput.startIndex,
+ offsetBy: min(context.coordinator.lastOutput.count, terminalOutput.count)))
+
+ if !newOutput.isEmpty {
+ context.coordinator.lastOutput = terminalOutput
+ if context.coordinator.isWebViewLoaded {
+ context.coordinator.writeToTerminal(text: String(newOutput), webView: webView)
+ } else {
+ context.coordinator.pendingOutput = (context.coordinator.pendingOutput ?? "") + String(newOutput)
+ }
+ }
+ }
+ }
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator(self)
+ }
+
+ class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler {
+ var parent: XTermView
+ var lastOutput: String = ""
+ var isWebViewLoaded = false
+ var pendingOutput: String?
+
+ init(_ parent: XTermView) {
+ self.parent = parent
+ super.init()
+ }
+
+ func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
+ isWebViewLoaded = true
+ if let pending = pendingOutput {
+ writeToTerminal(text: pending, webView: webView)
+ pendingOutput = nil
+ }
+ }
+
+ func writeToTerminal(text: String, webView: WKWebView) {
+ let escapedOutput = text
+ .replacingOccurrences(of: "\\", with: "\\\\")
+ .replacingOccurrences(of: "'", with: "\\'")
+ .replacingOccurrences(of: "\n", with: "\\r\\n")
+ .replacingOccurrences(of: "\r", with: "\\r")
+
+ let jsCode = "writeToTerminal('\(escapedOutput)');"
+ DispatchQueue.main.async {
+ webView.evaluateJavaScript(jsCode) { _, error in
+ if let error = error {
+ Logger.client.info("XTerm: Error writing to terminal: \(error)")
+ }
+ }
+ }
+ }
+
+ func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
+ if message.name == "terminalInput", let input = message.body as? String {
+ DispatchQueue.main.async {
+ self.parent.onTerminalInput(input)
+ }
+ }
+ }
+ }
+}
diff --git a/Core/Sources/ConversationTab/ViewExtension.swift b/Core/Sources/ConversationTab/ViewExtension.swift
index 6a4a0f81..181f3cbd 100644
--- a/Core/Sources/ConversationTab/ViewExtension.swift
+++ b/Core/Sources/ConversationTab/ViewExtension.swift
@@ -1,33 +1,44 @@
import SwiftUI
+import ComposableArchitecture
-let BLUE_IN_LIGHT_THEME = Color(red: 98/255, green: 154/255, blue: 248/255)
-let BLUE_IN_DARK_THEME = Color(red: 55/255, green: 108/255, blue: 194/255)
+let ITEM_SELECTED_COLOR = Color("ItemSelectedColor")
struct HoverBackgroundModifier: ViewModifier {
- @Environment(\.colorScheme) var colorScheme
var isHovered: Bool
func body(content: Content) -> some View {
content
- .background(isHovered ? (colorScheme == .dark ? BLUE_IN_DARK_THEME : BLUE_IN_LIGHT_THEME) : Color.clear)
+ .background(isHovered ? ITEM_SELECTED_COLOR : Color.clear)
}
}
struct HoverRadiusBackgroundModifier: ViewModifier {
- @Environment(\.colorScheme) var colorScheme
var isHovered: Bool
+ var hoverColor: Color?
var cornerRadius: CGFloat = 0
+ var showBorder: Bool = false
+ var borderColor: Color = .white.opacity(0.07)
+ var borderWidth: CGFloat = 1
func body(content: Content) -> some View {
- content.background(
- RoundedRectangle(cornerRadius: cornerRadius)
- .fill(isHovered ? (colorScheme == .dark ? BLUE_IN_DARK_THEME : BLUE_IN_LIGHT_THEME) : Color.clear)
+ content
+ .background(
+ RoundedRectangle(cornerRadius: cornerRadius)
+ .fill(isHovered ? hoverColor ?? ITEM_SELECTED_COLOR : Color.clear)
+ )
+ .clipShape(
+ RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
+ )
+ .overlay(
+ (isHovered && showBorder) ?
+ RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
+ .strokeBorder(borderColor, lineWidth: borderWidth) :
+ nil
)
}
}
struct HoverForegroundModifier: ViewModifier {
- @Environment(\.colorScheme) var colorScheme
var isHovered: Bool
var defaultColor: Color
@@ -45,6 +56,22 @@ extension View {
self.modifier(HoverRadiusBackgroundModifier(isHovered: isHovered, cornerRadius: cornerRadius))
}
+ public func hoverRadiusBackground(isHovered: Bool, hoverColor: Color?, cornerRadius: CGFloat) -> some View {
+ self.modifier(HoverRadiusBackgroundModifier(isHovered: isHovered, hoverColor: hoverColor, cornerRadius: cornerRadius))
+ }
+
+ public func hoverRadiusBackground(isHovered: Bool, hoverColor: Color?, cornerRadius: CGFloat, showBorder: Bool, borderColor: Color = .white.opacity(0.07)) -> some View {
+ self.modifier(
+ HoverRadiusBackgroundModifier(
+ isHovered: isHovered,
+ hoverColor: hoverColor,
+ cornerRadius: cornerRadius,
+ showBorder: true,
+ borderColor: borderColor
+ )
+ )
+ }
+
public func hoverForeground(isHovered: Bool, defaultColor: Color) -> some View {
self.modifier(HoverForegroundModifier(isHovered: isHovered, defaultColor: defaultColor))
}
@@ -56,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 511d6037..8d75db57 100644
--- a/Core/Sources/ConversationTab/Views/BotMessage.swift
+++ b/Core/Sources/ConversationTab/Views/BotMessage.swift
@@ -5,57 +5,36 @@ import MarkdownUI
import SharedUIComponents
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 errorMessage: String?
+ let message: DisplayedChatMessage
let chat: StoreOf
+ 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 {
@@ -69,208 +48,358 @@ 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())
+ 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()
}
}
}
var body: some View {
- HStack {
- VStack(alignment: .leading, spacing: 8) {
- CopilotMessageHeader()
- .padding(.leading, 6)
-
- 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()
)
}
- }
- ThemedMarkdownText(text: text, chat: chat)
+ if !panelMessages.isEmpty {
+ WithPerceptionTracking {
+ ForEach(panelMessages.indices, id: \.self) { index in
+ FunctionMessage(text: panelMessages[index].message, chat: chat)
+ }
+ }
+ }
- if errorMessage != nil {
- HStack(spacing: 4) {
- Image(systemName: "info.circle")
- Text(errorMessage!)
- .font(.system(size: chatFontSize))
+ if editAgentRounds.count > 0 {
+ ProgressAgentRound(rounds: editAgentRounds, chat: chat, isStreaming: isThinkingStreaming())
}
- }
- ResponseToolBar(id: id, chat: chat, text: text)
- }
- .shadow(color: .black.opacity(0.05), radius: 6)
- .contextMenu {
- Button("Copy") {
- NSPasteboard.general.clearContents()
- NSPasteboard.general.setString(text, forType: .string)
+ if !text.isEmpty {
+ Group{
+ ThemedMarkdownText(text: text, chat: chat)
+ }
+ .scaledPadding(.leading, 2)
+ .scaledPadding(.vertical, 4)
+ }
+
+ if let codeReviewRound = codeReviewRound {
+ CodeReviewMainView(
+ store: chat, round: codeReviewRound
+ )
+ .frame(maxWidth: .infinity)
+ }
+
+ if !errorMessages.isEmpty {
+ buildErrorMessageView()
+ }
+
+ HStack {
+ if shouldShowTurnStatus() {
+ TurnStatusView(
+ message: message,
+ isSummarizingConversation: chat.isSummarizingConversation
+ )
+ .modify { view in
+ if message.turnStatus == .inProgress {
+ view
+ .scaledPadding(.leading, 6)
+ } else {
+ view
+ }
+ }
+ }
+
+ Spacer()
+
+ ResponseToolBar(
+ id: id,
+ chat: chat,
+ text: text,
+ message: message
+ )
+ .conditionalFontWeight(.medium)
+ .opacity(shouldShowToolBar() ? 1 : 0)
+ .scaledPadding(.trailing, -20)
+ }
}
-
- Button("Set as Extra System Prompt") {
- chat.send(.setAsExtraPromptButtonTapped(id))
+ .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)
}
-
- Divider()
-
- Button("Delete") {
- chat.send(.deleteMessageButtonTapped(id))
+ .onHover {
+ isHovering = $0
}
}
}
}
-}
-
-struct ReferenceList: View {
- let references: [ConversationReference]
- let chat: StoreOf
-
- private let maxVisibleItems: Int = 6
- @State private var itemHeight: CGFloat = 16
-
- @AppStorage(\.chatFontSize) var chatFontSize
- struct ReferenceView: View {
- let references: [ConversationReference]
- let chat: StoreOf
- @AppStorage(\.chatFontSize) var chatFontSize
- @Binding var itemHeight: CGFloat
-
- var body: some View {
- VStack(alignment: .leading, spacing: 0) {
- ForEach(0.. 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)
}
- .frame(maxWidth: .infinity, alignment: .leading)
}
- .buttonStyle(HoverButtonStyle())
- .background(GeometryReader { geometry in
- Color.clear.onAppear {
- itemHeight = geometry.size.height
- }
- })
- .help(reference.getPathRelativeToHome())
}
}
}
}
+ .scaledPadding(.vertical, 4)
+ }
+
+ private func isSettingsActionableError(_ message: String) -> Bool {
+ message == HardCodedToolRoundExceedErrorMessage ||
+ message == SSLCertificateErrorMessage
+ }
+
+ private func shouldShowTurnStatus() -> Bool {
+ guard isLatestAssistantMessage() else {
+ return false
+ }
+
+ 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 isHovering }
+
+ // For current message, only show toolbar when message is complete
+ return !chat.isReceivingMessage
+ }
+
+ private func isLatestAssistantMessage() -> Bool {
+ let lastMessage = chat.history.last
+ return lastMessage?.role == .assistant && lastMessage?.id == id
}
+ private func isThinkingStreaming() -> Bool {
+ guard isLatestAssistantMessage(), chat.isReceivingMessage else { return false }
+ switch message.turnStatus {
+ case .success, .error, .cancelled: return false
+ default: return true
+ }
+ }
+}
+
+private struct TurnStatusView: View {
+
+ let message: DisplayedChatMessage
+ let isSummarizingConversation: Bool
+
+ @AppStorage(\.chatFontSize) var chatFontSize
+
var body: some View {
- WithPerceptionTracking {
- if references.count <= maxVisibleItems {
- ReferenceView(references: references, chat: chat, itemHeight: $itemHeight)
- } else {
- HoverScrollView {
- ReferenceView(references: references, chat: chat, itemHeight: $itemHeight)
+ HStack(spacing: 0) {
+ if isSummarizingConversation {
+ summarizingStatus
+ } else if let turnStatus = message.turnStatus {
+ switch turnStatus {
+ case .inProgress:
+ inProgressStatus
+ case .success:
+ completedStatus
+ case .cancelled:
+ cancelStatus
+ case .error:
+ EmptyView()
+ case .waitForConfirmation:
+ waitForConfirmationStatus
}
}
}
- .frame(maxWidth: .infinity, maxHeight: maxViewHeight)
+ }
+
+ private var inProgressStatus: some View {
+ HStack(spacing: 4) {
+ ProgressView()
+ .controlSize(.small)
+ .scaledScaleEffect(0.7)
+ .scaledFrame(width: 16, height: 16)
+ Text("Generating...")
+ .scaledFont(size: chatFontSize - 1)
+ .foregroundColor(.secondary)
+ }
+ }
+
+ private var summarizingStatus: some View {
+ HStack(spacing: 4) {
+ ProgressView()
+ .controlSize(.small)
+ .scaledScaleEffect(0.7)
+ .scaledFrame(width: 16, height: 16)
+
+ Text("Summarizing conversation...")
+ .scaledFont(size: chatFontSize - 1)
+ .foregroundColor(.secondary)
+ }
}
- private var maxViewHeight: CGFloat {
- let totalHeight = CGFloat(references.count) * itemHeight
- let maxHeight = CGFloat(maxVisibleItems) * itemHeight
- return min(totalHeight, maxHeight)
+ private var completedStatus: some View {
+ statusView(icon: "checkmark.circle.fill", iconColor: .successLightGreen, text: "Completed")
+ }
+
+ private var waitForConfirmationStatus: some View {
+ statusView(icon: "clock.fill", iconColor: .brown, text: "Waiting for your response")
+ }
+
+ private var cancelStatus: some View {
+ statusView(icon: "slash.circle", iconColor: .secondary, text: "Stopped")
+ }
+
+ private var errorStatus: some View {
+ statusView(icon: "xmark.circle.fill", iconColor: .red, text: "Error Occurred")
+ }
+
+ private func statusView(icon: String, iconColor: Color, text: String) -> some View {
+ HStack(spacing: 4) {
+ Image(systemName: icon)
+ .scaledFont(size: chatFontSize)
+ .foregroundColor(iconColor)
+ .conditionalFontWeight(.medium)
+
+ Text(text)
+ .scaledFont(size: chatFontSize - 1)
+ .foregroundColor(.secondary)
+ }
}
}
-#Preview("Bot Message") {
- 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
- ), count: 2),
- followUp: ConversationFollowUp(message: "followup question", id: "id", type: "type"),
- errorMessage: "Sorry, an error occurred while generating a response.",
- chat: .init(initialState: .init(), reducer: { Chat(service: ChatService.service()) })
- )
- .padding()
- .fixedSize(horizontal: true, vertical: true)
-}
+struct BotMessage_Previews: PreviewProvider {
+ static let steps: [ConversationProgressStep] = [
+ .init(id: "001", title: "running step", description: "this is running step", status: .running, error: nil),
+ .init(id: "002", title: "completed step", description: "this is completed step", status: .completed, error: nil),
+ .init(id: "003", title: "failed step", description: "this is failed step", status: .failed, error: nil),
+ .init(id: "004", title: "cancelled step", description: "this is cancelled step", status: .cancelled, error: nil)
+ ]
-#Preview("Reference List") {
- ReferenceList(references: [
- .init(
- uri: "/Core/Sources/ConversationTab/Views/BotMessage.swift",
- status: .included,
- kind: .class
- ),
- .init(
- uri: "/Core/Sources/ConversationTab/Views",
- status: .included,
- kind: .struct
- ),
- .init(
- uri: "/Core/Sources/ConversationTab/Views/BotMessage.swift",
- status: .included,
- kind: .function
- ),
- .init(
- uri: "/Core/Sources/ConversationTab/Views/BotMessage.swift",
- status: .included,
- kind: .case
- ),
- .init(
- uri: "/Core/Sources/ConversationTab/Views/BotMessage.swift",
- status: .included,
- kind: .extension
- ),
- .init(
- uri: "/Core/Sources/ConversationTab/Views/BotMessage.swift",
- status: .included,
- kind: .webpage
- ),
- ], chat: .init(initialState: .init(), reducer: { Chat(service: ChatService.service()) }))
-}
+ static let agentRounds: [AgentRound] = [
+ .init(roundId: 1, reply: "this is agent step 1", toolCalls: [
+ .init(
+ id: "toolcall_001",
+ name: "Tool Call 1",
+ progressMessage: "Read Tool Call 1",
+ status: .completed,
+ error: nil)
+ ]),
+ .init(roundId: 2, reply: "this is agent step 2", toolCalls: [
+ .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")
+ BotMessage(
+ 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)) }),
+ )
+ .padding()
+ .fixedSize(horizontal: true, vertical: true)
+ }
+}
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
new file mode 100644
index 00000000..27256b87
--- /dev/null
+++ b/Core/Sources/ConversationTab/Views/CodeReviewRound/CodeReviewMainView.swift
@@ -0,0 +1,69 @@
+import ComposableArchitecture
+import ConversationServiceProvider
+import LanguageServerProtocol
+import SwiftUI
+import SharedUIComponents
+
+// MARK: - Main View
+
+struct CodeReviewMainView: View {
+ let store: StoreOf
+ let round: CodeReviewRound
+ @State private var selectedFileUris: [DocumentUri]
+ @AppStorage(\.chatFontSize) var chatFontSize
+
+ private var changedFileUris: [DocumentUri] {
+ round.request?.changedFileUris ?? []
+ }
+
+ private var hasChangedFiles: Bool {
+ !changedFileUris.isEmpty
+ }
+
+ private var hasFileComments: Bool {
+ guard let fileComments = round.response?.fileComments else { return false }
+ return !fileComments.isEmpty
+ }
+
+ static let HelloMessage: String = "Sure, I can help you with that."
+
+ public init(store: StoreOf, round: CodeReviewRound) {
+ self.store = store
+ self.round = round
+ self.selectedFileUris = round.request?.selectedFileUris ?? []
+ }
+
+ var helloMessageView: some View {
+ Text(Self.HelloMessage)
+ .scaledFont(.system(size: chatFontSize))
+ }
+
+ var shouldShowHelloMessage: Bool { round.statusHistory.contains(.waitForConfirmation) }
+
+ var body: some View {
+ WithPerceptionTracking {
+ VStack(alignment: .leading, spacing: 8) {
+ if shouldShowHelloMessage {
+ helloMessageView
+ }
+
+ if hasChangedFiles {
+ FileSelectionSection(
+ store: store,
+ round: round,
+ changedFileUris: changedFileUris,
+ selectedFileUris: $selectedFileUris
+ )
+ }
+
+ if hasFileComments {
+ ReviewResultsSection(store: store, round: round)
+ }
+
+ if round.status == .completed || round.status == .error {
+ ReviewSummarySection(round: round)
+ }
+ }
+ }
+ }
+}
diff --git a/Core/Sources/ConversationTab/Views/CodeReviewRound/FileSelectionSection.swift b/Core/Sources/ConversationTab/Views/CodeReviewRound/FileSelectionSection.swift
new file mode 100644
index 00000000..bc044fa7
--- /dev/null
+++ b/Core/Sources/ConversationTab/Views/CodeReviewRound/FileSelectionSection.swift
@@ -0,0 +1,277 @@
+import ComposableArchitecture
+import ConversationServiceProvider
+import LanguageServerProtocol
+import SharedUIComponents
+import SwiftUI
+
+// MARK: - File Selection Section
+
+struct FileSelectionSection: View {
+ let store: StoreOf
+ let round: CodeReviewRound
+ let changedFileUris: [DocumentUri]
+ @Binding var selectedFileUris: [DocumentUri]
+ @AppStorage(\.chatFontSize) private var chatFontSize
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ FileSelectionHeader(fileCount: selectedFileUris.count)
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ FileSelectionList(
+ store: store,
+ fileUris: changedFileUris,
+ reviewStatus: round.status,
+ selectedFileUris: $selectedFileUris
+ )
+
+ if round.status == .waitForConfirmation {
+ FileSelectionActions(
+ store: store,
+ roundId: round.id,
+ selectedFileUris: selectedFileUris
+ )
+ }
+ }
+ .padding(12)
+ .background(CodeReviewCardBackground())
+ }
+}
+
+// MARK: - File Selection Components
+
+private struct FileSelectionHeader: View {
+ let fileCount: Int
+ @AppStorage(\.chatFontSize) private var chatFontSize
+
+ var body: some View {
+ HStack(alignment: .top, spacing: 6) {
+ Image("codeReview")
+ .resizable()
+ .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.")
+ .scaledFont(.system(size: chatFontSize))
+ .multilineTextAlignment(.leading)
+ }
+ }
+}
+
+private struct FileSelectionActions: View {
+ let store: StoreOf
+ let roundId: String
+ let selectedFileUris: [DocumentUri]
+
+ var body: some View {
+ HStack(spacing: 4) {
+ Button("Cancel") {
+ store.send(.codeReview(.cancel(id: roundId)))
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.large)
+ .scaledFont(.body)
+
+ Button("Continue") {
+ store.send(.codeReview(.accept(id: roundId, selectedFiles: selectedFileUris)))
+ }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.large)
+ .scaledFont(.body)
+ }
+ }
+}
+
+// MARK: - File Selection List
+
+private struct FileSelectionList: View {
+ let store: StoreOf
+ let fileUris: [DocumentUri]
+ let reviewStatus: CodeReviewRound.Status
+ @State private var isExpanded = false
+ @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
+
+ private var hasMoreFiles: Bool {
+ fileUris.count > Self.defaultVisibleFileCount
+ }
+
+ var body: some View {
+ let visibleFileUris = Array(fileUris.prefix(Self.defaultVisibleFileCount))
+ let additionalFileUris = Array(fileUris.dropFirst(Self.defaultVisibleFileCount))
+
+ WithPerceptionTracking {
+ VStack(alignment: .leading, spacing: 4) {
+ // Select All checkbox for all files
+ selectedAllCheckbox
+ .disabled(reviewStatus != .waitForConfirmation)
+ .scaledFrame(maxHeight: 16)
+
+ FileToggleList(
+ fileUris: visibleFileUris,
+ reviewStatus: reviewStatus,
+ selectedFileUris: $selectedFileUris,
+ onSelectionChange: updateMixedState
+ )
+ .padding(.leading, 16)
+
+ if hasMoreFiles {
+ if !isExpanded {
+ ExpandFilesButton(isExpanded: $isExpanded)
+ }
+
+ if isExpanded {
+ FileToggleList(
+ fileUris: additionalFileUris,
+ reviewStatus: reviewStatus,
+ selectedFileUris: $selectedFileUris,
+ onSelectionChange: updateMixedState
+ )
+ .padding(.leading, 16)
+ }
+ }
+ }
+ }
+ .frame(alignment: .leading)
+ .onAppear {
+ updateMixedState()
+ }
+ }
+
+ private var selectedAllCheckbox: some 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 {
+ case .off, .mixed:
+ // Select all files
+ selectedFileUris = fileUris
+ case .on:
+ // Deselect all files
+ selectedFileUris = []
+ }
+ updateMixedState()
+ }
+ }
+
+ private func updateMixedState() {
+ let selectedSet = Set(selectedFileUris)
+ let selectedCount = fileUris.filter { selectedSet.contains($0) }.count
+ let totalCount = fileUris.count
+
+ if selectedCount == 0 {
+ checkboxMixedState = .off
+ } else if selectedCount == totalCount {
+ checkboxMixedState = .on
+ } else {
+ checkboxMixedState = .mixed
+ }
+ }
+}
+
+private struct ExpandFilesButton: View {
+ @Binding var isExpanded: Bool
+ @AppStorage(\.chatFontSize) private var chatFontSize
+
+ var body: some View {
+ HStack(spacing: 2) {
+ Image("chevron.down")
+ .resizable()
+ .scaledToFit()
+ .scaledFrame(width: 16, height: 16)
+
+ Button(action: { isExpanded = true }) {
+ Text("Show more")
+ .underline()
+ .scaledFont(.system(size: chatFontSize))
+ .lineSpacing(20)
+ }
+ .buttonStyle(PlainButtonStyle())
+ }
+ .foregroundColor(.blue)
+ }
+}
+
+private struct FileToggleList: View {
+ let fileUris: [DocumentUri]
+ let reviewStatus: CodeReviewRound.Status
+ @Binding var selectedFileUris: [DocumentUri]
+ let onSelectionChange: () -> Void
+
+ var body: some View {
+ ForEach(fileUris, id: \.self) { fileUri in
+ FileSelectionRow(
+ fileUri: fileUri,
+ reviewStatus: reviewStatus,
+ isSelected: createSelectionBinding(for: fileUri)
+ )
+ }
+ }
+
+ private func createSelectionBinding(for fileUri: DocumentUri) -> Binding {
+ Binding(
+ get: { selectedFileUris.contains(fileUri) },
+ set: { isSelected in
+ if isSelected {
+ if !selectedFileUris.contains(fileUri) {
+ selectedFileUris.append(fileUri)
+ }
+ } else {
+ selectedFileUris.removeAll { $0 == fileUri }
+ }
+
+ onSelectionChange()
+ }
+ )
+ }
+}
+
+private struct FileSelectionRow: View {
+ let fileUri: DocumentUri
+ let reviewStatus: CodeReviewRound.Status
+ @Binding var isSelected: Bool
+
+ private var fileURL: URL? {
+ URL(string: fileUri)
+ }
+
+ private var isInteractionEnabled: Bool {
+ reviewStatus == .waitForConfirmation
+ }
+
+ var body: some View {
+ HStack(alignment: .center) {
+ Toggle(isOn: $isSelected) {
+ HStack(spacing: 8) {
+ drawFileIcon(fileURL)
+ .scaledToFit()
+ .scaledFrame(width: 16, height: 16)
+
+ Text(fileURL?.lastPathComponent ?? fileUri)
+ .scaledFont(.body)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ }
+ }
+ .toggleStyle(CheckboxToggleStyle())
+ .disabled(!isInteractionEnabled)
+
+ Spacer()
+ }
+ }
+}
diff --git a/Core/Sources/ConversationTab/Views/CodeReviewRound/ReviewResultsSection.swift b/Core/Sources/ConversationTab/Views/CodeReviewRound/ReviewResultsSection.swift
new file mode 100644
index 00000000..1a239021
--- /dev/null
+++ b/Core/Sources/ConversationTab/Views/CodeReviewRound/ReviewResultsSection.swift
@@ -0,0 +1,183 @@
+import SwiftUI
+import ComposableArchitecture
+import ConversationServiceProvider
+import SharedUIComponents
+
+// MARK: - Review Results Section
+
+struct ReviewResultsSection: View {
+ let store: StoreOf
+ let round: CodeReviewRound
+ @State private var isExpanded = false
+ @AppStorage(\.chatFontSize) private var chatFontSize
+
+ private static let defaultVisibleReviewCount = 5
+
+ private var fileComments: [CodeReviewResponse.FileComment] {
+ round.response?.fileComments ?? []
+ }
+
+ private var visibleReviewCount: Int {
+ isExpanded ? fileComments.count : min(fileComments.count, Self.defaultVisibleReviewCount)
+ }
+
+ private var hasMoreReviews: Bool {
+ fileComments.count > Self.defaultVisibleReviewCount
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ ReviewResultsHeader(
+ reviewStatus: round.status,
+ chatFontSize: chatFontSize
+ )
+ .padding(8)
+ .background(CodeReviewHeaderBackground())
+
+ if !fileComments.isEmpty {
+ VStack(alignment: .leading, spacing: 4) {
+ ReviewResultsList(
+ store: store,
+ fileComments: Array(fileComments.prefix(visibleReviewCount))
+ )
+ }
+ .padding(.horizontal, 8)
+ .padding(.bottom, !hasMoreReviews || isExpanded ? 8 : 0)
+ }
+
+ if hasMoreReviews && !isExpanded {
+ ExpandReviewsButton(isExpanded: $isExpanded)
+ }
+ }
+ .background(CodeReviewCardBackground())
+ }
+}
+
+private struct ReviewResultsHeader: View {
+ let reviewStatus: CodeReviewRound.Status
+ let chatFontSize: CGFloat
+
+ var body: some View {
+ HStack(spacing: 4) {
+ Text("Reviewed Changes")
+ .scaledFont(size: chatFontSize)
+
+ Spacer()
+ }
+ }
+}
+
+
+private struct ExpandReviewsButton: View {
+ @Binding var isExpanded: Bool
+
+ var body: some View {
+ HStack {
+ Spacer()
+
+ Button {
+ isExpanded = true
+ } label: {
+ Image("chevron.down")
+ .resizable()
+ .scaledToFit()
+ .scaledFrame(width: 16, height: 16)
+ }
+ .buttonStyle(PlainButtonStyle())
+
+ Spacer()
+ }
+ .padding(.vertical, 2)
+ .background(CodeReviewHeaderBackground())
+ }
+}
+
+private struct ReviewResultsList: View {
+ let store: StoreOf
+ let fileComments: [CodeReviewResponse.FileComment]
+
+ var body: some View {
+ ForEach(fileComments, id: \.self) { fileComment in
+ if let fileURL = fileComment.url {
+ ReviewResultRow(
+ store: store,
+ fileURL: fileURL,
+ comments: fileComment.comments
+ )
+ }
+ }
+ }
+}
+
+private struct ReviewResultRow: View {
+ let store: StoreOf
+ let fileURL: URL
+ let comments: [ReviewComment]
+ @State private var isExpanded = false
+
+ private var commentCountText: String {
+ comments.count == 1 ? "1 comment" : "\(comments.count) comments"
+ }
+
+ private var hasComments: Bool {
+ !comments.isEmpty
+ }
+
+ var body: some View {
+ VStack(alignment: .leading) {
+ ReviewResultRowContent(
+ store: store,
+ fileURL: fileURL,
+ comments: comments,
+ commentCountText: commentCountText,
+ hasComments: hasComments
+ )
+ }
+ }
+}
+
+private struct ReviewResultRowContent: View {
+ let store: StoreOf
+ let fileURL: URL
+ let comments: [ReviewComment]
+ let commentCountText: String
+ let hasComments: Bool
+ @State private var isHovered: Bool = false
+
+ @AppStorage(\.chatFontSize) private var chatFontSize
+
+ var body: some View {
+ HStack(alignment: .center, spacing: 4) {
+ drawFileIcon(fileURL)
+ .scaledToFit()
+ .scaledFrame(width: 16, height: 16)
+
+ Button(action: {
+ if hasComments {
+ store.send(.codeReview(.onFileClicked(fileURL, comments[0].range.end.line)))
+ }
+ }) {
+ Text(fileURL.lastPathComponent)
+ .scaledFont(.system(size: chatFontSize))
+ .foregroundColor(isHovered ? Color("ItemSelectedColor") : .primary)
+ }
+ .buttonStyle(PlainButtonStyle())
+ .disabled(!hasComments)
+ .onHover { hovering in
+ isHovered = hovering
+ if hovering {
+ NSCursor.pointingHand.push()
+ } else {
+ NSCursor.pop()
+ }
+ }
+
+ Text(commentCountText)
+ .scaledFont(size: chatFontSize - 1)
+ .lineSpacing(20)
+ .foregroundColor(.secondary)
+
+ Spacer()
+ }
+ }
+}
diff --git a/Core/Sources/ConversationTab/Views/CodeReviewRound/ReviewSummarySection.swift b/Core/Sources/ConversationTab/Views/CodeReviewRound/ReviewSummarySection.swift
new file mode 100644
index 00000000..76fcbf6d
--- /dev/null
+++ b/Core/Sources/ConversationTab/Views/CodeReviewRound/ReviewSummarySection.swift
@@ -0,0 +1,49 @@
+import SwiftUI
+import ConversationServiceProvider
+import SharedUIComponents
+
+struct ReviewSummarySection: View {
+ var round: CodeReviewRound
+ @AppStorage(\.chatFontSize) var chatFontSize
+
+ var body: some View {
+ 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()
+ }
+ }
+}
+
+struct CompletedSummary: View {
+ var request: CodeReviewRequest
+ var response: CodeReviewResponse
+ @AppStorage(\.chatFontSize) var chatFontSize
+
+ var body: some View {
+ let changedFileUris = request.changedFileUris
+ let selectedFileUris = request.selectedFileUris
+ let allComments = response.allComments
+
+ VStack(alignment: .leading, spacing: 8) {
+
+ Text("Total comments: \(allComments.count)")
+
+ if allComments.count > 0 {
+ Text("Review complete! We found \(allComments.count) comment(s) in your selected file(s). Click a file name to see details in the editor.")
+ } else {
+ Text("Copilot reviewed \(selectedFileUris.count) out of \(changedFileUris.count) changed files, and no comments were found.")
+ }
+
+ }
+ .scaledFont(size: chatFontSize)
+ }
+}
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
new file mode 100644
index 00000000..739b126a
--- /dev/null
+++ b/Core/Sources/ConversationTab/Views/ConversationProgressStepView.swift
@@ -0,0 +1,84 @@
+import SwiftUI
+import ConversationServiceProvider
+import ComposableArchitecture
+import Combine
+import ChatService
+import SharedUIComponents
+
+struct ProgressStep: View {
+ let steps: [ConversationProgressStep]
+
+ var body: some View {
+ WithPerceptionTracking {
+ VStack(alignment: .leading, spacing: 4) {
+ ForEach(steps) { StatusItemView(step: $0) }
+ }
+ .foregroundStyle(.secondary)
+ }
+ }
+}
+
+
+struct StatusItemView: View {
+
+ let step: ConversationProgressStep
+
+ @AppStorage(\.chatFontSize) var chatFontSize
+
+ var statusIcon: some View {
+ Group {
+ switch step.status {
+ case .running:
+ ProgressView()
+ .controlSize(.small)
+ .scaledScaleEffect(0.7)
+ case .completed:
+ Image(systemName: "checkmark")
+ .foregroundColor(Color.successLightGreen)
+ case .failed:
+ Image(systemName: "xmark.circle")
+ .foregroundColor(.red)
+ case .cancelled:
+ Image(systemName: "slash.circle")
+ .foregroundColor(.gray)
+ }
+ }
+ .scaledFont(size: chatFontSize - 1, weight: .medium)
+ }
+
+ var statusTitleText: String {
+ if step.id == ProjectContextSkill.ProgressID && step.status == .failed {
+ return step.error?.message ?? step.title
+ }
+ return step.title
+ }
+
+ var body: some View {
+ WithPerceptionTracking {
+ HStack(spacing: 4) {
+ statusIcon
+ .scaledFrame(width: 16, height: 16)
+
+ Text(statusTitleText)
+ .scaledFont(size: chatFontSize - 1)
+ .lineLimit(1)
+
+ Spacer()
+ }
+ .help(statusTitleText)
+ }
+ }
+}
+
+struct ProgressStep_Preview: PreviewProvider {
+ static let steps: [ConversationProgressStep] = [
+ .init(id: "001", title: "running step", description: "this is running step", status: .running, error: nil),
+ .init(id: "002", title: "completed step", description: "this is completed step", status: .completed, error: nil),
+ .init(id: "003", title: "failed step", description: "this is failed step", status: .failed, error: nil),
+ .init(id: "004", title: "cancelled step", description: "this is cancelled step", status: .cancelled, error: nil)
+ ]
+ static var previews: some View {
+ ProgressStep(steps: steps)
+ .frame(width: 300, height: 300)
+ }
+}
diff --git a/Core/Sources/ConversationTab/Views/FunctionMessage.swift b/Core/Sources/ConversationTab/Views/FunctionMessage.swift
index a393b116..5686f0e9 100644
--- a/Core/Sources/ConversationTab/Views/FunctionMessage.swift
+++ b/Core/Sources/ConversationTab/Views/FunctionMessage.swift
@@ -3,133 +3,161 @@ import SwiftUI
import ChatService
import SharedUIComponents
import ComposableArchitecture
+import ChatTab
+import GitHubCopilotService
struct FunctionMessage: View {
- let chat: StoreOf
- let id: String
let text: String
+ let chat: StoreOf
@AppStorage(\.chatFontSize) var chatFontSize
@Environment(\.openURL) private var openURL
- private let displayFormatter: DateFormatter = {
- let formatter = DateFormatter()
- formatter.dateStyle = .long
- formatter.timeStyle = .short
- return formatter
- }()
+ private var isFreePlanUser: Bool {
+ text.contains("30-day free trial")
+ }
+
+ 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."
+ }
+ return ""
+ }
+
+ private var quotaActionButtons: [(title: String, urlString: String, isProminent: Bool)] {
+ let lower = text.lowercased()
+ let hasEnableOverage = lower.contains("enable additional overages")
+ let hasIncreaseBudget = lower.contains("increase budget")
+ let hasOverage = hasEnableOverage || hasIncreaseBudget
+ var buttons: [(String, String, Bool)] = []
+ if hasEnableOverage {
+ buttons.append(("Enable Additional Overage", "https://aka.ms/github-copilot-manage-overage", true))
+ }
+ if hasIncreaseBudget {
+ buttons.append(("Increase Budget", "https://aka.ms/github-copilot-manage-overage", true))
+ }
+ if lower.contains("upgrade your plan") {
+ buttons.append(("Upgrade Plan", "https://aka.ms/github-copilot-upgrade-plan", !hasOverage))
+ }
+ return buttons
+ }
+
+ private var errorContent: Text {
+ switch (isFreePlanUser, isOrgUser, isBYOKUser) {
+ case (true, _, _):
+ return Text("Monthly message limit reached. Upgrade to Copilot Pro (30-day free trial) or wait for your limit to reset.")
+
+ case (_, true, _):
+ let parts = [
+ "You have exceeded your free request allowance.",
+ switchToFallbackModelText,
+ "To enable additional paid premium requests, contact your organization admin."
+ ].filter { !$0.isEmpty }
+ return Text(attributedString(from: parts))
+
+ case (_, _, true):
+ let sentences = splitBYOKQuotaMessage(text)
+ guard sentences.count == 2 else { fallthrough }
+ let parts = [sentences[0], switchToFallbackModelText, sentences[1]].filter { !$0.isEmpty }
+ return Text(attributedString(from: parts))
+
+ default:
+ let parts = [text, switchToFallbackModelText].filter { !$0.isEmpty }
+ return Text(attributedString(from: parts))
+ }
+ }
- private func extractDate(from text: String) -> Date? {
- guard let match = (try? NSRegularExpression(pattern: "until (.*?) for"))?
- .firstMatch(in: text, options: [], range: NSRange(location: 0, length: text.utf16.count)),
- let dateRange = Range(match.range(at: 1), in: text) else {
- return nil
+ private func attributedString(from parts: [String]) -> AttributedString {
+ do {
+ return try AttributedString(markdown: parts.joined(separator: " "))
+ } catch {
+ return AttributedString(parts.joined(separator: " "))
}
+ }
- let dateString = String(text[dateRange])
- let formatter = DateFormatter()
- formatter.dateFormat = "M/d/yyyy, h:mm:ss a"
- return formatter.date(from: dateString)
+ 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 {
+ case .file:
+ if let fileUrl = item.fileUrl {
+ return fileUrl.lastPathComponent
+ } else {
+ return "Attached Image"
+ }
+ case .pasted:
+ return "Pasted Image"
+ case .screenshot:
+ return "Screenshot"
+ }
+ }
+
+ 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 text = getImageTitle()
+
+ Text(text)
+ .lineLimit(1)
+ .scaledFont(size: 12)
+ .truncationMode(.middle)
+ .scaledFrame(maxWidth: 105, alignment: .center)
+ .fixedSize(horizontal: true, vertical: false)
+
+ Image(systemName: "photo")
+ .resizable()
+ .scaledToFit()
+ .scaledPadding(.vertical, 2)
+ .scaledFrame(width: 16, height: 16)
+ }
+ .foregroundColor(.primary.opacity(0.85))
+ .scaledPadding(.horizontal, 4)
+ .scaledPadding(.vertical, 1)
+ .cornerRadius(6)
+ .overlay(
+ RoundedRectangle(cornerRadius: 6)
+ .inset(by: 0.5)
+ .stroke(Color(nsColor: .quaternaryLabelColor), lineWidth: 1 * fontScale)
+ )
+ .popover(isPresented: $showPopover, arrowEdge: .bottom) {
+ PopoverImageView(data: item.data)
+ }
+ .onTapGesture {
+ self.showPopover = true
+ }
+ }
+}
+
diff --git a/Core/Sources/ConversationTab/Views/NotificationBanner.swift b/Core/Sources/ConversationTab/Views/NotificationBanner.swift
new file mode 100644
index 00000000..89062b0a
--- /dev/null
+++ b/Core/Sources/ConversationTab/Views/NotificationBanner.swift
@@ -0,0 +1,60 @@
+import SwiftUI
+import SharedUIComponents
+
+public enum BannerStyle {
+ case info
+ case warning
+
+ var iconName: String {
+ switch self {
+ 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
+ }
+ }
+}
+
+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)
+ .foregroundColor(style.color)
+
+ VStack(alignment: .leading, spacing: 8) {
+ content()
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ if isDismissable {
+ Button(action: { onDismiss?() }) {
+ Image(systemName: "xmark")
+ .foregroundColor(.secondary)
+ }
+ .buttonStyle(HoverButtonStyle())
+ }
+ }
+ .scaledFont(size: chatFontSize - 1)
+ }
+ .frame(maxWidth: .infinity, alignment: .topLeading)
+ .scaledPadding(.vertical, 10)
+ .scaledPadding(.horizontal, 12)
+ .background(Color("BannerBackgroundColor"))
+ .overlay(
+ RoundedRectangle(cornerRadius: 8)
+ .stroke(Color("BannerBorderColor"), lineWidth: 1)
+ )
+ }
+}
diff --git a/Core/Sources/ConversationTab/Views/ThemedMarkdownText.swift b/Core/Sources/ConversationTab/Views/ThemedMarkdownText.swift
index fa133e60..f9a1409b 100644
--- a/Core/Sources/ConversationTab/Views/ThemedMarkdownText.swift
+++ b/Core/Sources/ConversationTab/Views/ThemedMarkdownText.swift
@@ -4,31 +4,71 @@ import SwiftUI
import ChatService
import ComposableArchitecture
import SuggestionBasic
+import ChatTab
+import SharedUIComponents
-struct ThemedMarkdownText: View {
+public struct MarkdownActionProvider {
+ let supportInsert: Bool
+ let onInsert: ((String) -> Void)?
+
+ public init(supportInsert: Bool = true, onInsert: ((String) -> Void)? = nil) {
+ self.supportInsert = supportInsert
+ self.onInsert = onInsert
+ }
+}
+
+public struct ThemedMarkdownText: View {
@AppStorage(\.syncChatCodeHighlightTheme) var syncCodeHighlightTheme
@AppStorage(\.codeForegroundColorLight) var codeForegroundColorLight
@AppStorage(\.codeBackgroundColorLight) var codeBackgroundColorLight
@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 chat: StoreOf
+ let 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) {
self.text = text
- self.chat = chat
+
+ self.context = .init(onInsert: { content in
+ chat.send(.insertCode(content))
+ })
+ self.foregroundColor = Self.defaultForegroundColor
}
- var body: some View {
+ 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 {
@@ -52,7 +92,7 @@ struct ThemedMarkdownText: View {
}
return Color.secondary.opacity(0.7)
}(),
- chat: chat
+ context: context
))
}
}
@@ -62,13 +102,14 @@ struct ThemedMarkdownText: View {
extension MarkdownUI.Theme {
static func custom(
fontSize: Double,
+ foregroundColor: Color,
codeFont: NSFont,
codeBlockBackgroundColor: Color,
codeBlockLabelColor: Color,
- chat: StoreOf
+ context: MarkdownActionProvider
) -> MarkdownUI.Theme {
.gitHub.text {
- ForegroundColor(.primary)
+ ForegroundColor(foregroundColor)
BackgroundColor(Color.clear)
FontSize(fontSize)
}
@@ -78,7 +119,7 @@ extension MarkdownUI.Theme {
codeFont: codeFont,
codeBlockBackgroundColor: codeBlockBackgroundColor,
codeBlockLabelColor: codeBlockLabelColor,
- chat: chat
+ context: context
)
}
}
@@ -89,11 +130,7 @@ struct MarkdownCodeBlockView: View {
let codeFont: NSFont
let codeBlockBackgroundColor: Color
let codeBlockLabelColor: Color
- let chat: StoreOf
-
- func insertCode() {
- chat.send(.insertCode(codeBlockConfiguration.content))
- }
+ let context: MarkdownActionProvider
var body: some View {
let wrapCode = UserDefaults.shared.value(for: \.wrapCodeInChatCodeBlock)
@@ -109,8 +146,10 @@ struct MarkdownCodeBlockView: View {
codeBlockConfiguration,
backgroundColor: codeBlockBackgroundColor,
labelColor: codeBlockLabelColor,
- insertAction: insertCode
+ context: context
)
+ // Force recreation when font size changes
+ .id("code-block-\(codeFont.pointSize)")
} else {
ScrollView(.horizontal) {
AsyncCodeBlockView(
@@ -125,21 +164,24 @@ struct MarkdownCodeBlockView: View {
codeBlockConfiguration,
backgroundColor: codeBlockBackgroundColor,
labelColor: codeBlockLabelColor,
- insertAction: insertCode
+ context: context
)
+ // Force recreation when font size changes
+ .id("code-block-\(codeFont.pointSize)")
}
}
}
-#Preview("Themed Markdown Text") {
- ThemedMarkdownText(
- text:"""
-```swift
-let sumClosure: (Int, Int) -> Int = { (a: Int, b: Int) in
- return a + b
-}
-```
-""",
- chat: .init(initialState: .init(), reducer: { Chat(service: ChatService.service()) }))
+struct ThemedMarkdownText_Previews: PreviewProvider {
+ static var previews: some View {
+ ThemedMarkdownText(
+ 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 0342bdaa..4b8a22e3 100644
--- a/Core/Sources/ConversationTab/Views/UserMessage.swift
+++ b/Core/Sources/ConversationTab/Views/UserMessage.swift
@@ -6,77 +6,144 @@ import SharedUIComponents
import SwiftUI
import Status
import Cache
+import ChatTab
+import ConversationServiceProvider
+import SwiftUIFlowLayout
+import ChatAPIService
+
+private let MAX_TEXT_LENGTH = 10000 // Maximum characters to prevent crashes
struct UserMessage: View {
var r: Double { messageBubbleCornerRadius }
let id: String
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
+ @State var isMessageHovering: Bool = false
+
+ // Truncate the displayed user message if it's too long.
+ private var displayText: String {
+ if text.count > MAX_TEXT_LENGTH {
+ return String(text.prefix(MAX_TEXT_LENGTH)) + "\n… (message too long, rest hidden)"
+ }
+ return text
+ }
- 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)
- }
+ 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()
- }
+ 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)
- ThemedMarkdownText(text: text, chat: chat)
- .frame(maxWidth: .infinity, alignment: .leading)
+ 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)
}
}
-#Preview {
- UserMessage(
- id: "A",
- text: #"""
- Please buy me a coffee!
- | Coffee | Milk |
- |--------|------|
- | Espresso | No |
- | Latte | Yes |
- ```swift
- func foo() {}
- ```
- ```objectivec
- - (void)bar {}
- ```
- """#,
- chat: .init(
- initialState: .init(history: [] as [DisplayedChatMessage], isReceivingMessage: false),
- reducer: { Chat(service: ChatService.service()) }
+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
)
- )
- .padding()
- .fixedSize(horizontal: true, vertical: true)
- .background(Color.yellow)
+ .frame(maxWidth: .infinity)
+ }
}
+struct UserMessage_Previews: PreviewProvider {
+ static var previews: some View {
+ let chatTabInfo = ChatTabInfo(id: "id", workspacePath: "path", username: "name")
+ UserMessage(
+ id: "A",
+ text: #"""
+ Please buy me a coffee!
+ | Coffee | Milk |
+ |--------|------|
+ | Espresso | No |
+ | Latte | Yes |
+ ```swift
+ func foo() {}
+ ```
+ ```objectivec
+ - (void)bar {}
+ ```
+ """#,
+ imageReferences: [],
+ 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)
+ .background(Color.yellow)
+
+ }
+}
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
new file mode 100644
index 00000000..f572454b
--- /dev/null
+++ b/Core/Sources/ConversationTab/Views/WorkingSetView.swift
@@ -0,0 +1,263 @@
+import SwiftUI
+import ChatService
+import Perception
+import ComposableArchitecture
+import GitHubCopilotService
+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, isExpanded: $isExpanded)
+ .scaledPadding(.vertical, 2)
+ .scaledPadding(.leading, 7)
+
+ if isExpanded {
+ VStack(spacing: 0) {
+ ForEach(chat.fileEditMap.elements, id: \.key.path) { element in
+ FileEditView(chat: chat, fileEdit: element.value)
+ }
+ }
+ }
+ }
+ .scaledPadding(.horizontal, 5)
+ .scaledPadding(.vertical, 4)
+ .frame(maxWidth: .infinity)
+ .background(
+ RoundedCorners(tl: r, tr: r, bl: 0, br: 0)
+ .fill(.ultraThickMaterial)
+ )
+ .overlay(
+ RoundedCorners(tl: r, tr: r, bl: 0, br: 0)
+ .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
+ )
+ }
+ }
+}
+
+struct WorkingSetHeader: View {
+ let chat: StoreOf
+ @Binding var isExpanded: Bool
+
+ @Environment(\.colorScheme) var colorScheme
+
+ func getTitle() -> String {
+ return chat.fileEditMap.count > 1 ? "\(chat.fileEditMap.count) files changed" : "1 file changed"
+ }
+
+ @ViewBuilder
+ private func buildActionButton(
+ text: String,
+ textForegroundColor: Color = .white,
+ textBackgroundColor: Color = .gray,
+ buttonStyle: some PrimitiveButtonStyle = .bordered,
+ action: @escaping () -> Void
+ ) -> some View {
+ Button(action: action) {
+ Text(text)
+ .scaledFont(size: 11)
+ }
+ .buttonStyle(buttonStyle)
+ }
+
+ var body: some View {
+ WithPerceptionTracking {
+ HStack(spacing: 0) {
+ 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: 6) {
+ /// Undo all edits
+ buildActionButton(
+ text: "Undo",
+ textForegroundColor: colorScheme == .dark ? .white : .black,
+ textBackgroundColor: Color("WorkingSetHeaderUndoButtonColor")
+ ) {
+ chat.send(.undoEdits(fileURLs: chat.fileEditMap.values.map { $0.fileURL }))
+ }
+ .help("Undo All Edits")
+
+ /// Keep all edits
+ buildActionButton(
+ text: "Keep",
+ textBackgroundColor: Color("WorkingSetHeaderKeepButtonColor"),
+ buttonStyle: .borderedProminent
+ ) {
+ chat.send(.keepEdits(fileURLs: chat.fileEditMap.values.map { $0.fileURL }))
+ }
+ .help("Keep All Edits")
+ }
+
+ } else {
+ buildActionButton(text: "Done") {
+ chat.send(.resetEdits)
+ }
+ .help("Done")
+ }
+ }
+
+ }
+ }
+}
+
+struct FileEditView: View {
+ let chat: StoreOf
+ let fileEdit: FileEdit
+ @State private var isHovering = false
+
+ enum ActionButtonImageType {
+ case system(String), asset(String)
+ }
+
+ @ViewBuilder
+ private func buildActionButton(
+ imageType: ActionButtonImageType,
+ help: String,
+ action: @escaping () -> Void
+ ) -> some View {
+ Button(action: action) {
+ Group {
+ switch imageType {
+ case .system(let name):
+ Image(systemName: name)
+ .scaledFont(size: 15, weight: .regular)
+ case .asset(let name):
+ Image(name)
+ .renderingMode(.template)
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .scaledFrame(height: 16)
+ }
+ }
+ .foregroundColor(.white)
+ .scaledFrame(width: 22)
+ .frame(maxHeight: .infinity)
+ }
+ .buttonStyle(HoverButtonStyle(padding: 0, hoverColor: .white.opacity(0.2)))
+ .help(help)
+ }
+
+ var actionButtons: some View {
+ HStack(spacing: 0) {
+ if fileEdit.status == .none {
+ buildActionButton(
+ imageType: .system("xmark"),
+ help: "Remove file"
+ ) {
+ chat.send(.discardFileEdits(fileURLs: [fileEdit.fileURL]))
+ }
+ buildActionButton(
+ imageType: .asset("DiffEditor"),
+ help: "Open changes in Diff Editor"
+ ) {
+ chat.send(.openDiffViewWindow(fileURL: fileEdit.fileURL))
+ }
+ buildActionButton(
+ imageType: .asset("Discard"),
+ help: "Undo"
+ ) {
+ chat.send(.undoEdits(fileURLs: [fileEdit.fileURL]))
+ }
+ buildActionButton(
+ imageType: .system("checkmark"),
+ help: "Keep"
+ ) {
+ chat.send(.keepEdits(fileURLs: [fileEdit.fileURL]))
+ }
+ }
+ }
+ }
+
+ var body: some View {
+ HStack(spacing: 0) {
+ HStack(alignment: .center, spacing: 4) {
+ drawFileIcon(fileEdit.fileURL)
+ .scaledToFit()
+ .scaledFrame(width: 16, height: 16)
+ .foregroundColor(.secondary)
+
+ Text(fileEdit.fileURL.lastPathComponent)
+ .scaledFont(size: 13)
+ .foregroundColor(isHovering ? .white : Color("WorkingSetItemColor"))
+ }
+
+ Spacer()
+
+ if isHovering {
+ actionButtons
+ .padding(.trailing, 8)
+ }
+ }
+ .onHover { hovering in
+ isHovering = hovering
+ }
+ .scaledPadding(.leading, 7)
+ .scaledFrame(height: 24)
+ .hoverRadiusBackground(
+ isHovered: isHovering,
+ hoverColor: Color.blue,
+ cornerRadius: 5,
+ showBorder: true
+ )
+ .onTapGesture {
+ chat.send(.openDiffViewWindow(fileURL: fileEdit.fileURL))
+ }
+ }
+}
+
+
+struct WorkingSetView_Previews: PreviewProvider {
+ static let fileEditMap: OrderedDictionary = [
+ URL(fileURLWithPath: "file:///f1.swift"): FileEdit(fileURL: URL(fileURLWithPath: "file:///f1.swift"), originalContent: "single line", modifiedContent: "single line 1", toolName: ToolName.insertEditIntoFile),
+ URL(fileURLWithPath: "file:///f2.swift"): FileEdit(fileURL: URL(fileURLWithPath: "file:///f2.swift"), originalContent: "multi \n line \n end", modifiedContent: "another \n mut \n li \n", status: .kept, toolName: ToolName.insertEditIntoFile)
+ ]
+
+ static var previews: some View {
+ WorkingSetView(
+ chat: .init(
+ initialState: .init(
+ history: ChatPanel_Preview.history,
+ isReceivingMessage: true,
+ fileEditMap: fileEditMap
+ ),
+ reducer: { Chat(service: ChatService.service(for: ChatPanel_Preview.chatTabInfo)) }
+ )
+ )
+ }
+}
diff --git a/Core/Sources/ConversationTab/VisionViews/HoverableImageView.swift b/Core/Sources/ConversationTab/VisionViews/HoverableImageView.swift
new file mode 100644
index 00000000..42ec5eb5
--- /dev/null
+++ b/Core/Sources/ConversationTab/VisionViews/HoverableImageView.swift
@@ -0,0 +1,160 @@
+import SwiftUI
+import ComposableArchitecture
+import Persist
+import ConversationServiceProvider
+import GitHubCopilotService
+import SharedUIComponents
+
+public struct HoverableImageView: View {
+ @Environment(\.colorScheme) var colorScheme
+
+ let image: ImageReference
+ let chat: StoreOf
+ @State private var isHovered = false
+ @State private var hoverTask: Task?
+ @State private var isSelectedModelSupportVision = AppState.shared.isSelectedModelSupportVision() ?? CopilotModelManager.getDefaultChatModel(scope: AppState.shared.modelScope())?.supportVision ?? false
+ @State private var showPopover = false
+
+ let maxWidth: CGFloat = 330
+ let maxHeight: CGFloat = 160
+
+ private var visionNotSupportedOverlay: some View {
+ Group {
+ if !isSelectedModelSupportVision {
+ ZStack {
+ Color.clear
+ .background(.regularMaterial)
+ .opacity(0.4)
+ .clipShape(RoundedRectangle(cornerRadius: hoverableImageCornerRadius))
+
+ VStack(alignment: .center, spacing: 8) {
+ Image(systemName: "eye.slash")
+ .font(.system(size: 14, weight: .semibold))
+ Text("Vision not supported by current model")
+ .font(.system(size: 12, weight: .semibold))
+ .multilineTextAlignment(.center)
+ .padding(.horizontal, 20)
+ }
+ .foregroundColor(colorScheme == .dark ? .primary : .white)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ .colorScheme(colorScheme == .dark ? .light : .dark)
+ }
+ }
+ }
+
+ private var borderOverlay: some View {
+ RoundedRectangle(cornerRadius: hoverableImageCornerRadius)
+ .strokeBorder(Color(nsColor: .separatorColor), lineWidth: 1)
+ }
+
+ private var removeButton: some View {
+ Button(action: {
+ chat.send(.removeSelectedImage(image))
+ }) {
+ Image(systemName: "xmark")
+ .foregroundColor(.primary)
+ .scaledFont(.system(size: 13))
+ .frame(width: 24, height: 24)
+ .background(
+ RoundedRectangle(cornerRadius: hoverableImageCornerRadius)
+ .fill(Color.contentBackground.opacity(0.72))
+ .shadow(color: .black.opacity(0.3), radius: 1.5, x: 0, y: 0)
+ .shadow(color: .black.opacity(0.25), radius: 50, x: 0, y: 36)
+ )
+ }
+ .buttonStyle(.plain)
+ .padding(1)
+ .onHover { buttonHovering in
+ hoverTask?.cancel()
+ if buttonHovering {
+ isHovered = true
+ }
+ }
+ }
+
+ private var hoverOverlay: some View {
+ Group {
+ if isHovered {
+ VStack {
+ Spacer()
+ HStack {
+ removeButton
+ Spacer()
+ }
+ }
+ }
+ }
+ }
+
+ private var baseImageView: some View {
+ let (image, nsImage) = loadImageFromData(data: image.data)
+ let imageSize = nsImage?.size ?? CGSize(width: maxWidth, height: maxHeight)
+ let isWideImage = imageSize.height < 160 && imageSize.width >= maxWidth
+
+ return image
+ .resizable()
+ .aspectRatio(contentMode: isWideImage ? .fill : .fit)
+ .blur(radius: !isSelectedModelSupportVision ? 2.5 : 0)
+ .frame(
+ width: isWideImage ? min(imageSize.width, maxWidth) : nil,
+ height: isWideImage ? min(imageSize.height, maxHeight) : maxHeight,
+ alignment: .leading
+ )
+ .clipShape(
+ RoundedRectangle(cornerRadius: hoverableImageCornerRadius),
+ style: .init(eoFill: true, antialiased: true)
+ )
+ }
+
+ private func handleHover(_ hovering: Bool) {
+ hoverTask?.cancel()
+
+ if hovering {
+ isHovered = true
+ } else {
+ // Add a small delay before hiding to prevent flashing
+ hoverTask = Task {
+ try? await Task.sleep(nanoseconds: 10_000_000) // 0.01 seconds
+ if !Task.isCancelled {
+ isHovered = false
+ }
+ }
+ }
+ }
+
+ private func updateVisionSupport() {
+ isSelectedModelSupportVision = AppState.shared.isSelectedModelSupportVision() ?? CopilotModelManager.getDefaultChatModel(scope: AppState.shared.modelScope())?.supportVision ?? false
+ }
+
+ public var body: some View {
+ if NSImage(data: image.data) != nil {
+ baseImageView
+ .frame(height: maxHeight, alignment: .leading)
+ .background(
+ Color(nsColor: .windowBackgroundColor).opacity(0.5)
+ )
+ .overlay(visionNotSupportedOverlay)
+ .overlay(borderOverlay)
+ .onHover(perform: handleHover)
+ .overlay(hoverOverlay)
+ .onReceive(NotificationCenter.default.publisher(for: .gitHubCopilotSelectedModelDidChange)) { _ in
+ updateVisionSupport()
+ }
+ .onTapGesture {
+ showPopover.toggle()
+ }
+ .popover(isPresented: $showPopover, attachmentAnchor: .rect(.bounds), arrowEdge: .bottom) {
+ PopoverImageView(data: image.data)
+ }
+ }
+ }
+}
+
+public func loadImageFromData(data: Data) -> (image: Image, nsImage: NSImage?) {
+ if let nsImage = NSImage(data: data) {
+ return (Image(nsImage: nsImage), nsImage)
+ } else {
+ return (Image(systemName: "photo.trianglebadge.exclamationmark"), nil)
+ }
+}
diff --git a/Core/Sources/ConversationTab/VisionViews/ImagesScrollView.swift b/Core/Sources/ConversationTab/VisionViews/ImagesScrollView.swift
new file mode 100644
index 00000000..c2e3d6b8
--- /dev/null
+++ b/Core/Sources/ConversationTab/VisionViews/ImagesScrollView.swift
@@ -0,0 +1,18 @@
+import SwiftUI
+import ComposableArchitecture
+
+public struct ImagesScrollView: View {
+ let chat: StoreOf
+ let editorMode: Chat.EditorMode
+
+ public var body: some View {
+ let attachedImages = chat.state.getChatContext(of: editorMode).attachedImages.reversed()
+ return ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 2) {
+ ForEach(attachedImages, id: \.self) { image in
+ HoverableImageView(image: image, chat: chat)
+ }
+ }
+ }
+ }
+}
diff --git a/Core/Sources/ConversationTab/VisionViews/PopoverImageView.swift b/Core/Sources/ConversationTab/VisionViews/PopoverImageView.swift
new file mode 100644
index 00000000..0beddb8c
--- /dev/null
+++ b/Core/Sources/ConversationTab/VisionViews/PopoverImageView.swift
@@ -0,0 +1,18 @@
+import SwiftUI
+
+public struct PopoverImageView: View {
+ let data: Data
+
+ public var body: some View {
+ let maxHeight: CGFloat = 400
+ let (image, nsImage) = loadImageFromData(data: data)
+ let height = nsImage.map { min($0.size.height, maxHeight) } ?? maxHeight
+
+ return image
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(height: height)
+ .clipShape(RoundedRectangle(cornerRadius: 10))
+ .padding(10)
+ }
+}
diff --git a/Core/Sources/ConversationTab/VisionViews/VisionMenuView.swift b/Core/Sources/ConversationTab/VisionViews/VisionMenuView.swift
new file mode 100644
index 00000000..ca12c71a
--- /dev/null
+++ b/Core/Sources/ConversationTab/VisionViews/VisionMenuView.swift
@@ -0,0 +1,137 @@
+import SwiftUI
+import SharedUIComponents
+import Logger
+import ComposableArchitecture
+import ConversationServiceProvider
+import AppKit
+import UniformTypeIdentifiers
+
+public struct VisionMenuView: View {
+ let chat: StoreOf
+ @AppStorage(\.capturePermissionShown) var capturePermissionShown: Bool
+ @State private var shouldPresentScreenRecordingPermissionAlert: Bool = false
+
+ func showImagePicker() {
+ let panel = NSOpenPanel()
+ panel.allowedContentTypes = [.png, .jpeg, .bmp, .gif, .tiff, .webP]
+ panel.allowsMultipleSelection = true
+ panel.canChooseFiles = true
+ panel.canChooseDirectories = false
+ panel.level = .modalPanel
+
+ // Position the panel relative to the current window
+ if let window = NSApplication.shared.keyWindow {
+ let windowFrame = window.frame
+ let panelSize = CGSize(width: 600, height: 400)
+ let x = windowFrame.midX - panelSize.width / 2
+ let y = windowFrame.midY - panelSize.height / 2
+ panel.setFrame(NSRect(origin: CGPoint(x: x, y: y), size: panelSize), display: true)
+ }
+
+ panel.begin { response in
+ if response == .OK {
+ let selectedImageURLs = panel.urls
+ handleSelectedImages(selectedImageURLs)
+ }
+ }
+ }
+
+ func handleSelectedImages(_ urls: [URL]) {
+ for url in urls {
+ let gotAccess = url.startAccessingSecurityScopedResource()
+ if gotAccess {
+ // Process the image file
+ if let imageData = try? Data(contentsOf: url) {
+ // imageData now contains the binary data of the image
+ Logger.client.info("Add selected image from URL: \(url)")
+ let imageReference = ImageReference(data: imageData, fileUrl: url)
+ chat.send(.addSelectedImage(imageReference))
+ }
+
+ url.stopAccessingSecurityScopedResource()
+ }
+ }
+ }
+
+ func runScreenCapture(args: [String] = []) {
+ let hasScreenRecordingPermission = CGPreflightScreenCaptureAccess()
+ if !hasScreenRecordingPermission {
+ if capturePermissionShown {
+ shouldPresentScreenRecordingPermissionAlert = true
+ } else {
+ CGRequestScreenCaptureAccess()
+ capturePermissionShown = true
+ }
+ return
+ }
+
+ let task = Process()
+ task.launchPath = "/usr/sbin/screencapture"
+ task.arguments = args
+ task.terminationHandler = { _ in
+ DispatchQueue.main.async {
+ if task.terminationStatus == 0 {
+ if let data = NSPasteboard.general.data(forType: .png) {
+ chat.send(.addSelectedImage(ImageReference(data: data, source: .screenshot)))
+ } else if let tiffData = NSPasteboard.general.data(forType: .tiff),
+ let imageRep = NSBitmapImageRep(data: tiffData),
+ let pngData = imageRep.representation(using: .png, properties: [:]) {
+ chat.send(.addSelectedImage(ImageReference(data: pngData, source: .screenshot)))
+ }
+ }
+ }
+ }
+ task.launch()
+ task.waitUntilExit()
+ }
+
+ public var body: some View {
+ Menu {
+ Button(action: { runScreenCapture(args: ["-w", "-c"]) }) {
+ 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)
+ .scaledFrame(width: 16, height: 16)
+ .scaledPadding(4)
+ .foregroundColor(.primary.opacity(0.85))
+ .scaledFont(size: 11, weight: .semibold)
+ }
+ .buttonStyle(HoverButtonStyle(padding: 0))
+ .help("Attach images")
+ .cornerRadius(6)
+ .alert(
+ "Enable Screen & System Recording Permission",
+ isPresented: $shouldPresentScreenRecordingPermissionAlert
+ ) {
+ Button(
+ "Open System Settings",
+ 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 7c369ed9..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
@@ -17,7 +19,6 @@ public class GitHubCopilotViewModel: ObservableObject {
public static let shared = GitHubCopilotViewModel()
@Dependency(\.toast) var toast
- @Dependency(\.openURL) var openURL
@AppStorage("username") var username: String = ""
@@ -30,6 +31,9 @@ public class GitHubCopilotViewModel: ObservableObject {
@Published public var waitingForSignIn = false
static var copilotAuthService: GitHubCopilotService?
+
+ private var lastQuotaCheckDate: Date?
+ private static let quotaCacheInterval: TimeInterval = 60 // seconds
// Make init private to enforce singleton pattern
private init() {}
@@ -106,7 +110,8 @@ public class GitHubCopilotViewModel: ObservableObject {
let service = try getGitHubCopilotAuthService()
status = try await service.signOut()
await Status.shared.updateAuthStatus(.notLoggedIn)
- await Status.shared.updateCLSStatus(.unknown, message: "")
+ await Status.shared.updateCLSStatus(.unknown, busy: false, message: "")
+ await Status.shared.updateQuotaInfo(nil)
username = ""
broadcastStatusChange()
} catch {
@@ -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)
@@ -136,13 +141,11 @@ public class GitHubCopilotViewModel: ObservableObject {
pasteboard.declareTypes([NSPasteboard.PasteboardType.string], owner: nil)
pasteboard.setString(signInResponse.userCode, forType: NSPasteboard.PasteboardType.string)
toast("Sign-in code \(signInResponse.userCode) copied", .info)
- Task {
- await openURL(signInResponse.verificationURL)
- waitForSignIn()
- }
+ NSWorkspace.shared.open(signInResponse.verificationURL)
+ waitForSignIn(fromHostApp: fromHostApp)
}
- public func waitForSignIn() {
+ public func waitForSignIn(fromHostApp: Bool = false) {
Task {
do {
guard waitingForSignIn else { return }
@@ -157,19 +160,206 @@ public class GitHubCopilotViewModel: ObservableObject {
self.status = status
await Status.shared.updateAuthStatus(.loggedIn, username: username)
broadcastStatusChange()
+ 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 {
- if case .languageServerError(.timeout) = error {
- // TODO figure out how to extend the default timeout on a Chime LSP request
- // Until then, reissue request
- waitForSignIn()
+ switch error {
+ case .languageServerError(.timeout):
+ waitForSignIn(fromHostApp: fromHostApp)
return
+ case .languageServerError(
+ .serverError(
+ code: CLSErrorCode.deviceFlowFailed.rawValue,
+ message: _,
+ data: _
+ )
+ ):
+ await showSignInFailedAlert(error: error)
+ waitingForSignIn = false
+ return
+ default:
+ throw error
}
- throw error
} catch {
toast(error.localizedDescription, .error)
}
}
}
+
+ private func extractSigninErrorMessage(error: GitHubCopilotError) -> String {
+ let errorDescription = error.localizedDescription
+
+ // Handle specific EACCES permission denied errors
+ if errorDescription.contains("EACCES") {
+ // Look for paths wrapped in single quotes
+ let pattern = "'([^']+)'"
+ if let regex = try? NSRegularExpression(pattern: pattern, options: []) {
+ let range = NSRange(location: 0, length: errorDescription.utf16.count)
+ if let match = regex.firstMatch(in: errorDescription, options: [], range: range) {
+ let pathRange = Range(match.range(at: 1), in: errorDescription)!
+ let path = String(errorDescription[pathRange])
+ return path
+ }
+ }
+ }
+
+ return errorDescription
+ }
+
+ private func getSigninErrorTitle(error: GitHubCopilotError) -> String {
+ let errorDescription = error.localizedDescription
+
+ if errorDescription.contains("EACCES") {
+ return "Can't sign you in. The app couldn't create or access files in"
+ }
+
+ return "Error details:"
+ }
+
+ private var accessPermissionCommands: String {
+ """
+ sudo mkdir -p ~/.config/github-copilot
+ sudo chown -R $(whoami):staff ~/.config
+ chmod -N ~/.config ~/.config/github-copilot
+ """
+ }
+
+ private var containerBackgroundColor: CGColor {
+ let isDarkMode = NSApp.effectiveAppearance.name == .darkAqua
+ return isDarkMode
+ ? NSColor.black.withAlphaComponent(0.85).cgColor
+ : NSColor.white.withAlphaComponent(0.85).cgColor
+ }
+
+ // MARK: - Alert Building Functions
+
+ private func showSignInFailedAlert(error: GitHubCopilotError) async {
+ let alert = NSAlert()
+ alert.messageText = "GitHub Copilot Sign-in Failed"
+ alert.alertStyle = .critical
+
+ let accessoryView = createAlertAccessoryView(error: error)
+ alert.accessoryView = accessoryView
+ alert.addButton(withTitle: "Copy Commands")
+ alert.addButton(withTitle: "Cancel")
+
+ let response = alert.runModal()
+
+ if response == .alertFirstButtonReturn {
+ copyCommandsToClipboard()
+ }
+ }
+
+ private func createAlertAccessoryView(error: GitHubCopilotError) -> NSView {
+ let accessoryView = NSView(frame: NSRect(x: 0, y: 0, width: 400, height: 142))
+
+ let detailsHeader = createDetailsHeader(error: error)
+ accessoryView.addSubview(detailsHeader)
+
+ let errorContainer = createErrorContainer(error: error)
+ accessoryView.addSubview(errorContainer)
+
+ let terminalHeader = createTerminalHeader()
+ accessoryView.addSubview(terminalHeader)
+
+ let commandsContainer = createCommandsContainer()
+ accessoryView.addSubview(commandsContainer)
+
+ return accessoryView
+ }
+
+ private func createDetailsHeader(error: GitHubCopilotError) -> NSView {
+ let detailsHeader = NSView(frame: NSRect(x: 16, y: 122, width: 368, height: 20))
+
+ let warningIcon = NSImageView(frame: NSRect(x: 0, y: 4, width: 16, height: 16))
+ warningIcon.image = NSImage(systemSymbolName: "exclamationmark.triangle.fill", accessibilityDescription: "Warning")
+ warningIcon.contentTintColor = NSColor.systemOrange
+ detailsHeader.addSubview(warningIcon)
+
+ let detailsLabel = NSTextField(wrappingLabelWithString: getSigninErrorTitle(error: error))
+ detailsLabel.frame = NSRect(x: 20, y: 0, width: 346, height: 20)
+ detailsLabel.font = NSFont.systemFont(ofSize: 12, weight: .regular)
+ detailsLabel.textColor = NSColor.labelColor
+ detailsHeader.addSubview(detailsLabel)
+
+ return detailsHeader
+ }
+
+ private func createErrorContainer(error: GitHubCopilotError) -> NSView {
+ let errorContainer = NSView(frame: NSRect(x: 16, y: 96, width: 368, height: 22))
+ errorContainer.wantsLayer = true
+ errorContainer.layer?.backgroundColor = containerBackgroundColor
+ errorContainer.layer?.borderColor = NSColor.separatorColor.cgColor
+ errorContainer.layer?.borderWidth = 1
+ errorContainer.layer?.cornerRadius = 6
+
+ let errorMessage = NSTextField(wrappingLabelWithString: extractSigninErrorMessage(error: error))
+ errorMessage.frame = NSRect(x: 8, y: 4, width: 368, height: 14)
+ errorMessage.font = NSFont.monospacedSystemFont(ofSize: 11, weight: .regular)
+ errorMessage.textColor = NSColor.labelColor
+ errorMessage.backgroundColor = .clear
+ errorMessage.isBordered = false
+ errorMessage.isEditable = false
+ errorMessage.drawsBackground = false
+ errorMessage.usesSingleLineMode = true
+ errorContainer.addSubview(errorMessage)
+
+ return errorContainer
+ }
+
+ private func createTerminalHeader() -> NSView {
+ let terminalHeader = NSView(frame: NSRect(x: 16, y: 66, width: 368, height: 20))
+
+ let toolIcon = NSImageView(frame: NSRect(x: 0, y: 4, width: 16, height: 16))
+ toolIcon.image = NSImage(systemSymbolName: "terminal.fill", accessibilityDescription: "Terminal")
+ toolIcon.contentTintColor = NSColor.secondaryLabelColor
+ terminalHeader.addSubview(toolIcon)
+
+ let terminalLabel = NSTextField(wrappingLabelWithString: "Copy and run the commands below in Terminal, then retry.")
+ terminalLabel.frame = NSRect(x: 20, y: 0, width: 346, height: 20)
+ terminalLabel.font = NSFont.systemFont(ofSize: 12, weight: .regular)
+ terminalLabel.textColor = NSColor.labelColor
+ terminalHeader.addSubview(terminalLabel)
+
+ return terminalHeader
+ }
+
+ private func createCommandsContainer() -> NSView {
+ let commandsContainer = NSView(frame: NSRect(x: 16, y: 4, width: 368, height: 58))
+ commandsContainer.wantsLayer = true
+ commandsContainer.layer?.backgroundColor = containerBackgroundColor
+ commandsContainer.layer?.borderColor = NSColor.separatorColor.cgColor
+ commandsContainer.layer?.borderWidth = 1
+ commandsContainer.layer?.cornerRadius = 6
+
+ let commandsText = NSTextField(wrappingLabelWithString: accessPermissionCommands)
+ commandsText.frame = NSRect(x: 8, y: 8, width: 344, height: 42)
+ commandsText.font = NSFont.monospacedSystemFont(ofSize: 11, weight: .regular)
+ commandsText.textColor = NSColor.labelColor
+ commandsText.backgroundColor = .clear
+ commandsText.isBordered = false
+ commandsText.isEditable = false
+ commandsText.isSelectable = true
+ commandsText.drawsBackground = false
+ commandsContainer.addSubview(commandsText)
+
+ return commandsContainer
+ }
+
+ private func copyCommandsToClipboard() {
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(
+ self.accessPermissionCommands.replacingOccurrences(of: "\n", with: " && "),
+ forType: .string
+ )
+ }
public func broadcastStatusChange() {
DistributedNotificationCenter.default().post(
@@ -177,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/AdvancedSettings.swift b/Core/Sources/HostApp/AdvancedSettings/AdvancedSettings.swift
index 384daad4..f0cfbaca 100644
--- a/Core/Sources/HostApp/AdvancedSettings/AdvancedSettings.swift
+++ b/Core/Sources/HostApp/AdvancedSettings/AdvancedSettings.swift
@@ -5,6 +5,7 @@ struct AdvancedSettings: View {
ScrollView {
VStack(alignment: .leading, spacing: 30) {
SuggestionSection()
+ ChatSection()
EnterpriseSection()
ProxySection()
LoggingSection()
diff --git a/Core/Sources/HostApp/AdvancedSettings/ChatSection.swift b/Core/Sources/HostApp/AdvancedSettings/ChatSection.swift
new file mode 100644
index 00000000..a8910979
--- /dev/null
+++ b/Core/Sources/HostApp/AdvancedSettings/ChatSection.swift
@@ -0,0 +1,591 @@
+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()
+
+ // Font Size
+ FontSizeSetting()
+ .padding(SettingsToggle.defaultPadding)
+
+ Divider()
+
+ if featureFlags.isAgentModeEnabled {
+ // Agent Max Tool Calling Requests
+ AgentMaxToolCallLoopSetting()
+ .padding(SettingsToggle.defaultPadding)
+
+ Divider()
+ }
+
+ // Auto Compress
+ AgentAutoCompressSetting()
+ }
+ }
+}
+
+struct ResponseLanguageSetting: View {
+ @AppStorage(\.chatResponseLocale) var chatResponseLocale
+
+ // Locale codes mapped to language display names
+ // reference: https://code.visualstudio.com/docs/configure/locales#_available-locales
+ private let localeLanguageMap: [String: String] = [
+ "en": "English",
+ "zh-cn": "Chinese, Simplified",
+ "zh-tw": "Chinese, Traditional",
+ "fr": "French",
+ "de": "German",
+ "it": "Italian",
+ "es": "Spanish",
+ "ja": "Japanese",
+ "ko": "Korean",
+ "ru": "Russian",
+ "pt-br": "Portuguese (Brazil)",
+ "tr": "Turkish",
+ "pl": "Polish",
+ "cs": "Czech",
+ "hu": "Hungarian",
+ ]
+
+ var selectedLanguage: String {
+ if chatResponseLocale == "" {
+ return "English"
+ }
+
+ return localeLanguageMap[chatResponseLocale] ?? "English"
+ }
+
+ // Display name to locale code mapping (for the picker UI)
+ var sortedLanguageOptions: [(displayName: String, localeCode: String)] {
+ localeLanguageMap.map { (displayName: $0.value, localeCode: $0.key) }
+ .sorted { $0.displayName < $1.displayName }
+ }
+
+ var body: some View {
+ WithPerceptionTracking {
+ HStack {
+ VStack(alignment: .leading) {
+ Text("Response Language")
+ .font(.body)
+ Text("This change applies only to new chat sessions. Existing ones won't be impacted.")
+ .font(.footnote)
+ }
+
+ Spacer()
+
+ Picker("", selection: $chatResponseLocale) {
+ ForEach(sortedLanguageOptions, id: \.localeCode) { option in
+ Text(option.displayName).tag(option.localeCode)
+ }
+ }
+ .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 {
+ @State var isGlobalInstructionsViewOpen = false
+ @Environment(\.toast) var toast
+
+ var body: some View {
+ WithPerceptionTracking {
+ HStack {
+ VStack(alignment: .leading) {
+ Text("Copilot Instructions")
+ .font(.body)
+ Text("Configure `.github/copilot-instructions.md` to apply to all chat requests.")
+ .font(.footnote)
+ }
+
+ Spacer()
+
+ Button("Current Workspace") {
+ openCustomInstructions()
+ }
+
+ Button("Global") {
+ isGlobalInstructionsViewOpen = true
+ }
+ }
+ .sheet(isPresented: $isGlobalInstructionsViewOpen) {
+ GlobalInstructionsView(isOpen: $isGlobalInstructionsViewOpen)
+ }
+ }
+ }
+
+ func openCustomInstructions() {
+ Task {
+ guard let projectURL = await getCurrentProjectURL() else {
+ toast("No active workspace found", .error)
+ return
+ }
+
+ let configFile = projectURL.appendingPathComponent(".github/copilot-instructions.md")
+
+ // If the file doesn't exist, create one with a proper structure
+ if !FileManager.default.fileExists(atPath: configFile.path) {
+ do {
+ // Create directory if it doesn't exist using reusable helper
+ let gitHubDir = projectURL.appendingPathComponent(".github")
+ try ensureDirectoryExists(at: gitHubDir)
+
+ // Create empty file
+ try "".write(to: configFile, atomically: true, encoding: .utf8)
+ } catch {
+ toast("Failed to create config file .github/copilot-instructions.md: \(error)", .error)
+ }
+ }
+
+ if FileManager.default.fileExists(atPath: configFile.path) {
+ NSWorkspace.shared.open(configFile)
+ }
+ }
+ }
+}
+
+struct PromptFileSetting: 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("Open \(promptType.directoryName.capitalized) Folder") {
+ 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)
+ NSWorkspace.shared.open(directory)
+ } catch {
+ toast("Failed to create \(promptType.directoryName) directory: \(error)", .error)
+ }
+ }
+ }
+}
+
+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
new file mode 100644
index 00000000..d93ae8d9
--- /dev/null
+++ b/Core/Sources/HostApp/AdvancedSettings/CustomCopilotHelper.swift
@@ -0,0 +1,64 @@
+import AppKit
+import Client
+import Foundation
+import SwiftUI
+import Toast
+import XcodeInspector
+import SystemUtils
+import SharedUIComponents
+import Workspace
+import LanguageServerProtocol
+
+// MARK: - Workspace URL Helpers
+
+private func getCurrentWorkspaceURL() async -> URL? {
+ guard let service = try? getService(),
+ let inspectorData = try? await service.getXcodeInspectorData() else {
+ return nil
+ }
+
+ if let url = inspectorData.realtimeActiveWorkspaceURL,
+ let workspaceURL = URL(string: url),
+ workspaceURL.path != "/" {
+ return workspaceURL
+ } else if let url = inspectorData.latestNonRootWorkspaceURL {
+ return URL(string: url)
+ }
+
+ return nil
+}
+
+func getCurrentProjectURL() async -> URL? {
+ guard let workspaceURL = await getCurrentWorkspaceURL(),
+ let projectURL = WorkspaceXcodeWindowInspector.extractProjectURL(
+ workspaceURL: workspaceURL,
+ documentURL: nil
+ ) else {
+ return nil
+ }
+
+ 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) {
+ try fileManager.createDirectory(at: url, withIntermediateDirectories: true, attributes: nil)
+ }
+}
diff --git a/Core/Sources/HostApp/AdvancedSettings/DisabledLanguageList.swift b/Core/Sources/HostApp/AdvancedSettings/DisabledLanguageList.swift
index cec78edc..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())
}
}
@@ -33,19 +28,24 @@ struct DisabledLanguageList: View {
var body: some View {
VStack(spacing: 0) {
- HStack {
- Button(action: {
- self.isOpen.wrappedValue = false
- }) {
- Image(systemName: "xmark.circle.fill")
- .foregroundStyle(.secondary)
- .padding()
+ ZStack(alignment: .topLeading) {
+ Rectangle().fill(Color(nsColor: .separatorColor)).frame(height: 28)
+
+ HStack {
+ Button(action: {
+ self.isOpen.wrappedValue = false
+ }) {
+ Image(systemName: "xmark.circle.fill")
+ .foregroundStyle(.secondary)
+ .padding()
+ }
+ .buttonStyle(.plain)
+ Text("Disabled Languages")
+ .font(.system(size: 13, weight: .bold))
+ Spacer()
}
- .buttonStyle(.plain)
- Text("Disabled Languages")
- Spacer()
+ .frame(height: 28)
}
- .background(Color(nsColor: .separatorColor))
List {
ForEach(
@@ -75,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/EnterpriseSection.swift b/Core/Sources/HostApp/AdvancedSettings/EnterpriseSection.swift
index bcd0adf2..f0a21a57 100644
--- a/Core/Sources/HostApp/AdvancedSettings/EnterpriseSection.swift
+++ b/Core/Sources/HostApp/AdvancedSettings/EnterpriseSection.swift
@@ -1,4 +1,5 @@
import Combine
+import Client
import SwiftUI
import Toast
@@ -11,7 +12,8 @@ struct EnterpriseSection: View {
SettingsTextField(
title: "Auth provider URL",
prompt: "https://your-enterprise.ghe.com",
- text: DebouncedBinding($gitHubCopilotEnterpriseURI, handler: urlChanged).binding
+ text: $gitHubCopilotEnterpriseURI,
+ onDebouncedChange: { url in urlChanged(url)}
)
}
}
@@ -24,15 +26,26 @@ struct EnterpriseSection: View {
name: .gitHubCopilotShouldRefreshEditorInformation,
object: nil
)
+ Task {
+ do {
+ let service = try getService()
+ try await service.postNotification(
+ name: Notification.Name
+ .gitHubCopilotShouldRefreshEditorInformation.rawValue
+ )
+ } catch {
+ toast(error.localizedDescription, .error)
+ }
+ }
}
func validateAuthURL(_ url: String) {
let maybeURL = URL(string: url)
- guard let parsedURl = maybeURL else {
+ guard let parsedURL = maybeURL else {
toast("Invalid URL", .error)
return
}
- if parsedURl.scheme != "https" {
+ if parsedURL.scheme != "https" {
toast("URL scheme must be https://", .error)
return
}
diff --git a/Core/Sources/HostApp/AdvancedSettings/GlobalInstructionsView.swift b/Core/Sources/HostApp/AdvancedSettings/GlobalInstructionsView.swift
new file mode 100644
index 00000000..264002a2
--- /dev/null
+++ b/Core/Sources/HostApp/AdvancedSettings/GlobalInstructionsView.swift
@@ -0,0 +1,83 @@
+import Client
+import SwiftUI
+import Toast
+
+struct GlobalInstructionsView: View {
+ var isOpen: Binding
+ @State var initValue: String = ""
+ @AppStorage(\.globalCopilotInstructions) var globalInstructions: String
+ @Environment(\.toast) var toast
+
+ init(isOpen: Binding) {
+ self.isOpen = isOpen
+ self.initValue = globalInstructions
+ }
+
+ var body: some View {
+ VStack(spacing: 0) {
+ ZStack(alignment: .topLeading) {
+ Rectangle().fill(Color(nsColor: .separatorColor)).frame(height: 28)
+
+ HStack {
+ Button(action: {
+ self.isOpen.wrappedValue = false
+ }) {
+ Image(systemName: "xmark.circle.fill")
+ .foregroundStyle(.secondary)
+ .padding()
+ }
+ .buttonStyle(.plain)
+ Text("Global Copilot Instructions")
+ .font(.system(size: 13, weight: .bold))
+ Spacer()
+ }
+ .frame(height: 28)
+ }
+
+ ZStack(alignment: .topLeading) {
+ TextEditor(text: $globalInstructions)
+ .font(.body)
+
+ if globalInstructions.isEmpty {
+ Text("Type your global instructions here...")
+ .foregroundColor(Color(nsColor: .placeholderTextColor))
+ .font(.body)
+ .allowsHitTesting(false)
+ .padding(.horizontal, 6)
+ }
+ }
+ .padding(8)
+ .background(Color(nsColor: .textBackgroundColor))
+ }
+ .focusable(false)
+ .frame(width: 300, height: 400)
+ .onAppear() {
+ self.initValue = globalInstructions
+ }
+ .onDisappear(){
+ self.isOpen.wrappedValue = false
+ if globalInstructions != initValue {
+ refreshConfiguration()
+ }
+ }
+ }
+
+ func refreshConfiguration() {
+ NotificationCenter.default.post(
+ name: .gitHubCopilotShouldRefreshEditorInformation,
+ object: nil
+ )
+ Task {
+ do {
+ let service = try getService()
+ // Notify extension service process to refresh all its CLS subprocesses to apply new configuration
+ try await service.postNotification(
+ name: Notification.Name
+ .gitHubCopilotShouldRefreshEditorInformation.rawValue
+ )
+ } catch {
+ toast(error.localizedDescription, .error)
+ }
+ }
+ }
+}
diff --git a/Core/Sources/HostApp/AdvancedSettings/ProxySection.swift b/Core/Sources/HostApp/AdvancedSettings/ProxySection.swift
index 168bdb1f..ab2062c7 100644
--- a/Core/Sources/HostApp/AdvancedSettings/ProxySection.swift
+++ b/Core/Sources/HostApp/AdvancedSettings/ProxySection.swift
@@ -15,37 +15,38 @@ struct ProxySection: View {
SettingsTextField(
title: "Proxy URL",
prompt: "http://host:port",
- text: wrapBinding($gitHubCopilotProxyUrl)
+ text: $gitHubCopilotProxyUrl,
+ onDebouncedChange: { _ in refreshConfiguration() }
)
SettingsTextField(
title: "Proxy username",
prompt: "username",
- text: wrapBinding($gitHubCopilotProxyUsername)
+ text: $gitHubCopilotProxyUsername,
+ onDebouncedChange: { _ in refreshConfiguration() }
)
- SettingsSecureField(
+ SettingsTextField(
title: "Proxy password",
prompt: "password",
- text: wrapBinding($gitHubCopilotProxyPassword)
+ text: $gitHubCopilotProxyPassword,
+ isSecure: true,
+ onDebouncedChange: { _ in refreshConfiguration() }
)
SettingsToggle(
title: "Proxy strict SSL",
- isOn: wrapBinding($gitHubCopilotUseStrictSSL)
+ isOn: $gitHubCopilotUseStrictSSL
)
+ .onChange(of: gitHubCopilotUseStrictSSL) { _ in refreshConfiguration() }
}
}
- private func wrapBinding(_ b: Binding) -> Binding {
- DebouncedBinding(b, handler: refreshConfiguration).binding
- }
-
- func refreshConfiguration(_: Any) {
+ func refreshConfiguration() {
NotificationCenter.default.post(
name: .gitHubCopilotShouldRefreshEditorInformation,
object: nil
)
Task {
- let service = try getService()
do {
+ let service = try getService()
try await service.postNotification(
name: Notification.Name
.gitHubCopilotShouldRefreshEditorInformation.rawValue
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
new file mode 100644
index 00000000..50d569da
--- /dev/null
+++ b/Core/Sources/HostApp/BYOKConfigView.swift
@@ -0,0 +1,72 @@
+import Client
+import GitHubCopilotService
+import SwiftUI
+
+public struct BYOKConfigView: View {
+ @StateObject private var dataManager = BYOKModelManagerObservable()
+ @State private var activeSheet: BYOKSheetType?
+ @State private var expansionStates: [BYOKProvider: Bool] = [:]
+
+ private let providers: [BYOKProvider] = [
+ .Azure,
+ .OpenAI,
+ .Anthropic,
+ .Gemini,
+ .Groq,
+ .OpenRouter,
+ ]
+
+ private var expansionHash: Int {
+ expansionStates.values.map { $0 ? 1 : 0 }.reduce(0, +)
+ }
+
+ private func expansionBinding(for provider: BYOKProvider) -> Binding {
+ Binding(
+ get: { expansionStates[provider] ?? false },
+ set: { expansionStates[provider] = $0 }
+ )
+ }
+
+ public var body: some View {
+ ScrollView {
+ LazyVStack(spacing: 8) {
+ ForEach(providers, id: \.self) { provider in
+ BYOKProviderConfigView(
+ provider: provider,
+ dataManager: dataManager,
+ onSheetRequested: presentSheet,
+ isExpanded: expansionBinding(for: provider)
+ )
+ }
+ }
+ .padding(16)
+ }
+ .animation(.easeInOut(duration: 0.3), value: expansionHash)
+ .onAppear {
+ Task {
+ await dataManager.refreshData()
+ }
+ }
+ .sheet(item: $activeSheet) { sheetType in
+ createSheetContent(for: sheetType)
+ }
+ }
+
+ // MARK: - Sheet Management
+
+ /// Presents the requested sheet type
+ private func presentSheet(_ sheetType: BYOKSheetType) {
+ activeSheet = sheetType
+ }
+
+ /// Creates the appropriate sheet content based on the sheet type
+ @ViewBuilder
+ private func createSheetContent(for sheetType: BYOKSheetType) -> some View {
+ switch sheetType {
+ case let .apiKey(provider):
+ ApiKeySheet(dataManager: dataManager, provider: provider)
+ case let .model(provider, model):
+ ModelSheet(dataManager: dataManager, provider: provider, existingModel: model)
+ }
+ }
+}
diff --git a/Core/Sources/HostApp/BYOKSettings/ApiKeySheet.swift b/Core/Sources/HostApp/BYOKSettings/ApiKeySheet.swift
new file mode 100644
index 00000000..4f93eee0
--- /dev/null
+++ b/Core/Sources/HostApp/BYOKSettings/ApiKeySheet.swift
@@ -0,0 +1,153 @@
+import GitHubCopilotService
+import SwiftUI
+import SharedUIComponents
+
+struct ApiKeySheet: View {
+ @ObservedObject var dataManager: BYOKModelManagerObservable
+ @Environment(\.dismiss) private var dismiss
+
+ @State private var apiKey = ""
+ @State private var showDeleteConfirmation = false
+ @State private var showPopOver = false
+ @State private var keepCustomModels = true
+ let provider: BYOKProvider
+
+ private var hasExistingApiKey: Bool {
+ dataManager.hasApiKey(for: provider)
+ }
+
+ private var isFormInvalid: Bool {
+ apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ }
+
+ var body: some View {
+ Form {
+ VStack(alignment: .center, spacing: 20) {
+ HStack(alignment: .center) {
+ Spacer()
+ Text("\(provider.title)").font(.headline)
+ Spacer()
+ AdaptiveHelpLink(action: openHelpLink)
+ }
+
+ VStack(alignment: .leading, spacing: 4) {
+ TextFieldsContainer {
+ SecureField("API Key", text: $apiKey)
+ }
+
+ if hasExistingApiKey {
+ HStack(spacing: 8) {
+ Toggle("Keep Custom Models", isOn: $keepCustomModels)
+ .toggleStyle(CheckboxToggleStyle())
+
+ Button(action: {}) {
+ Image(systemName: "questionmark.circle")
+ }
+ .buttonStyle(.borderless)
+ .foregroundStyle(.primary)
+ .onHover { hovering in
+ showPopOver = hovering
+ }
+ .popover(isPresented: $showPopOver, arrowEdge: .bottom) {
+ Text("Retains custom models \nafter API key updates.")
+ .multilineTextAlignment(.leading)
+ .padding(4)
+ }
+ }
+ .padding(.horizontal, 8)
+ .padding(.vertical, 6)
+ }
+ }
+
+ HStack(spacing: 8) {
+ if hasExistingApiKey {
+ Button("Delete", role: .destructive) {
+ showDeleteConfirmation = true
+ }
+ .confirmationDialog(
+ "Delete \(provider.title) API Key?",
+ isPresented: $showDeleteConfirmation
+ ) {
+ Button("Cancel", role: .cancel) { }
+ Button("Delete", role: .destructive) { deleteApiKey() }
+ } message: {
+ Text("This will remove all linked models and configurations. Still want to delete it?")
+ }
+ }
+
+ Spacer()
+ Button("Cancel", role: .cancel) { dismiss() }
+ Button(hasExistingApiKey ? "Update" : "Add") { updateApiKey() }
+ .buttonStyle(.borderedProminent)
+ .disabled(isFormInvalid)
+ }
+ }
+ .textFieldStyle(.plain)
+ .multilineTextAlignment(.trailing)
+ .padding(20)
+ }
+ .onAppear {
+ loadExistingApiKey()
+ }
+ }
+
+ private func loadExistingApiKey() {
+ apiKey = dataManager.filteredApiKeys(for: provider).first?.apiKey ?? ""
+ }
+
+ private func updateApiKey() {
+ Task {
+ do {
+ let trimmedApiKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
+
+ var savedCustomModels: [BYOKModelInfo] = []
+
+ // If updating an existing API key and keeping custom models, save them first
+ if hasExistingApiKey && keepCustomModels {
+ savedCustomModels = dataManager.filteredModels(for: provider)
+ .filter { $0.isCustomModel }
+ }
+
+ // For updates, delete the original API key first
+ if hasExistingApiKey {
+ try await dataManager.deleteApiKey(providerName: provider)
+ }
+
+ // Save the new API key
+ try await dataManager.saveApiKey(trimmedApiKey, providerName: provider)
+
+ // If we saved custom models and should keep them, restore them
+ if hasExistingApiKey && keepCustomModels && !savedCustomModels.isEmpty {
+ for customModel in savedCustomModels {
+ // Restore the custom model with the same properties
+ try await dataManager.saveModel(customModel)
+ }
+ }
+
+ dismiss()
+
+ // Fetch default models from the provider
+ await dataManager.listModelsWithFetch(providerName: provider)
+ } catch {
+ // Error is already handled in dataManager methods
+ // The error message will be displayed in the provider view
+ }
+ }
+ }
+
+ private func deleteApiKey() {
+ Task {
+ do {
+ try await dataManager.deleteApiKey(providerName: provider)
+ dismiss()
+ } catch {
+ // Error handling could be improved here, but keeping it simple for now
+ // The error will be reflected in the UI when the sheet dismisses
+ }
+ }
+ }
+
+ private func openHelpLink() {
+ NSWorkspace.shared.open(URL(string: BYOKHelpLink)!)
+ }
+}
diff --git a/Core/Sources/HostApp/BYOKSettings/BYOKObservable.swift b/Core/Sources/HostApp/BYOKSettings/BYOKObservable.swift
new file mode 100644
index 00000000..fa0bff5f
--- /dev/null
+++ b/Core/Sources/HostApp/BYOKSettings/BYOKObservable.swift
@@ -0,0 +1,243 @@
+import Client
+import GitHubCopilotService
+import Logger
+import SwiftUI
+import XPCShared
+import SystemUtils
+
+actor BYOKServiceActor {
+ private let service: XPCExtensionService
+
+ // MARK: - Write Serialization
+ // Chains write operations so only one mutating request is in-flight at a time.
+ private var writeQueue: Task? = nil
+
+ /// Enqueue a mutating operation ensuring strict sequential execution.
+ private func enqueueWrite(_ op: @escaping () async throws -> Void) async throws {
+ return try await withCheckedThrowingContinuation { continuation in
+ let previousQueue = writeQueue
+ writeQueue = Task {
+ // Wait for all previous operations to complete
+ await previousQueue?.value
+
+ // Now execute this operation
+ do {
+ try await op()
+ continuation.resume()
+ } catch {
+ continuation.resume(throwing: error)
+ }
+ }
+ }
+ }
+
+ init(serviceFactory: () throws -> XPCExtensionService) rethrows {
+ self.service = try serviceFactory()
+ }
+
+ // MARK: - Listing (reads can stay concurrent)
+ func listApiKeys() async throws -> [BYOKApiKeyInfo] {
+ let resp = try await service.listBYOKApiKey(BYOKListApiKeysParams())
+ return resp?.apiKeys ?? []
+ }
+
+ func listModels(providerName: BYOKProviderName? = nil,
+ enableFetchUrl: Bool? = nil) async throws -> [BYOKModelInfo] {
+ let params = BYOKListModelsParams(providerName: providerName,
+ enableFetchUrl: enableFetchUrl)
+ let resp = try await service.listBYOKModels(params)
+ return resp?.models ?? []
+ }
+
+ // MARK: - Mutations (serialized)
+ func saveModel(_ model: BYOKModelInfo) async throws {
+ try await enqueueWrite { [service] in
+ _ = try await service.saveBYOKModel(model)
+ }
+ }
+
+ func deleteModel(providerName: BYOKProviderName, modelId: String) async throws {
+ try await enqueueWrite { [service] in
+ let params = BYOKDeleteModelParams(providerName: providerName, modelId: modelId)
+ _ = try await service.deleteBYOKModel(params)
+ }
+ }
+
+ func saveApiKey(_ apiKey: String, providerName: BYOKProviderName) async throws {
+ try await enqueueWrite { [service] in
+ let params = BYOKSaveApiKeyParams(providerName: providerName, apiKey: apiKey)
+ _ = try await service.saveBYOKApiKey(params)
+ }
+ }
+
+ func deleteApiKey(providerName: BYOKProviderName) async throws {
+ try await enqueueWrite { [service] in
+ let params = BYOKDeleteApiKeyParams(providerName: providerName)
+ _ = try await service.deleteBYOKApiKey(params)
+ }
+ }
+}
+
+@MainActor
+class BYOKModelManagerObservable: ObservableObject {
+ @Published var availableBYOKApiKeys: [BYOKApiKeyInfo] = []
+ @Published var availableBYOKModels: [BYOKModelInfo] = []
+ @Published var errorMessages: [BYOKProviderName: String] = [:]
+ @Published var providerLoadingStates: [BYOKProviderName: Bool] = [:]
+
+ private let serviceActor: BYOKServiceActor
+
+ init() {
+ self.serviceActor = try! BYOKServiceActor {
+ try getService() // existing factory
+ }
+ }
+
+ func refreshData() async {
+ do {
+ // Serialized by actor (even though we still parallelize logically, calls run one by one)
+ async let apiKeys = serviceActor.listApiKeys()
+ async let models = serviceActor.listModels()
+
+ availableBYOKApiKeys = try await apiKeys
+ availableBYOKModels = try await models.sorted()
+ } catch {
+ Logger.client.error("Failed to refresh BYOK data: \(error)")
+ }
+ }
+
+ func deleteModel(_ model: BYOKModelInfo) async throws {
+ try await serviceActor.deleteModel(providerName: model.providerName, modelId: model.modelId)
+ await refreshData()
+ }
+
+ func saveModel(_ modelInfo: BYOKModelInfo) async throws {
+ try await serviceActor.saveModel(modelInfo)
+ await refreshData()
+ }
+
+ func saveApiKey(_ apiKey: String, providerName: BYOKProviderName) async throws {
+ try await serviceActor.saveApiKey(apiKey, providerName: providerName)
+ await refreshData()
+ }
+
+ func deleteApiKey(providerName: BYOKProviderName) async throws {
+ try await serviceActor.deleteApiKey(providerName: providerName)
+ errorMessages[providerName] = nil
+ await refreshData()
+ }
+
+ func listModelsWithFetch(providerName: BYOKProviderName) async {
+ providerLoadingStates[providerName] = true
+ errorMessages[providerName] = nil
+ defer { providerLoadingStates[providerName] = false }
+ do {
+ _ = try await serviceActor.listModels(providerName: providerName, enableFetchUrl: true)
+ await refreshData()
+ } catch {
+ errorMessages[providerName] = error.localizedDescription
+ }
+ }
+
+ func updateAllModels(providerName: BYOKProviderName, isRegistered: Bool) async throws {
+ let current = availableBYOKModels.filter { $0.providerName == providerName && $0.isRegistered != isRegistered }
+ guard !current.isEmpty else { return }
+ for model in current {
+ var updated = model
+ updated.isRegistered = isRegistered
+ try await serviceActor.saveModel(updated)
+ }
+ await refreshData()
+ }
+}
+
+// MARK: - Provider-specific Data Filtering
+
+extension BYOKModelManagerObservable {
+ func filteredApiKeys(for provider: BYOKProviderName, modelId: String? = nil) -> [BYOKApiKeyInfo] {
+ availableBYOKApiKeys.filter { apiKey in
+ apiKey.providerName == provider && (modelId == nil || apiKey.modelId == modelId)
+ }
+ }
+
+ func filteredModels(for provider: BYOKProviderName) -> [BYOKModelInfo] {
+ availableBYOKModels.filter { $0.providerName == provider }
+ }
+
+ func hasApiKey(for provider: BYOKProviderName) -> Bool {
+ !filteredApiKeys(for: provider).isEmpty
+ }
+
+ func hasModels(for provider: BYOKProviderName) -> Bool {
+ !filteredModels(for: provider).isEmpty
+ }
+
+ func isLoadingProvider(_ provider: BYOKProviderName) -> Bool {
+ providerLoadingStates[provider] ?? false
+ }
+}
+
+public var BYOKHelpLink: String {
+ var editorPluginVersion = SystemUtils.editorPluginVersionString
+ if editorPluginVersion == "0.0.0" {
+ editorPluginVersion = "main"
+ }
+ return "https://github.com/github/CopilotForXcode/blob/\(editorPluginVersion)/Docs/BYOK.md"
+}
+
+enum BYOKSheetType: Identifiable {
+ case apiKey(BYOKProviderName)
+ case model(BYOKProviderName, BYOKModelInfo? = nil)
+
+ var id: String {
+ switch self {
+ case let .apiKey(provider):
+ return "apiKey_\(provider.rawValue)"
+ case let .model(provider, model):
+ if let model = model {
+ return "editModel_\(provider.rawValue)_\(model.modelId)"
+ } else {
+ return "model_\(provider.rawValue)"
+ }
+ }
+ }
+}
+
+enum BYOKAuthType {
+ case GlobalApiKey
+ case PerModelDeployment
+
+ var helpText: String {
+ switch self {
+ case .GlobalApiKey:
+ return "Requires a single API key for all models"
+ case .PerModelDeployment:
+ return "Requires both deployment URL and API key per model"
+ }
+ }
+}
+
+extension BYOKProviderName {
+ var title: String {
+ switch self {
+ case .Azure: return "Azure"
+ case .Anthropic: return "Anthropic"
+ case .Gemini: return "Gemini"
+ case .Groq: return "Groq"
+ case .OpenAI: return "OpenAI"
+ case .OpenRouter: return "OpenRouter"
+ }
+ }
+
+ // MARK: - Configuration Type
+
+ /// The configuration approach used by this provider
+ var authType: BYOKAuthType {
+ switch self {
+ case .Anthropic, .Gemini, .Groq, .OpenAI, .OpenRouter: return .GlobalApiKey
+ case .Azure: return .PerModelDeployment
+ }
+ }
+}
+
+typealias BYOKProvider = BYOKProviderName
diff --git a/Core/Sources/HostApp/BYOKSettings/ModelRowView.swift b/Core/Sources/HostApp/BYOKSettings/ModelRowView.swift
new file mode 100644
index 00000000..d8487d23
--- /dev/null
+++ b/Core/Sources/HostApp/BYOKSettings/ModelRowView.swift
@@ -0,0 +1,111 @@
+import GitHubCopilotService
+import Logger
+import SharedUIComponents
+import SwiftUI
+
+struct ModelRowView: View {
+ var model: BYOKModelInfo
+ @ObservedObject var dataManager: BYOKModelManagerObservable
+ let isSelected: Bool
+ let onSelection: () -> Void
+ let onEditRequested: ((BYOKModelInfo) -> Void)? // New callback for edit action
+ @State private var isHovered: Bool = false
+
+ // Extract foreground colors to computed properties
+ private var primaryForegroundColor: Color {
+ isSelected ? Color(nsColor: .white) : .primary
+ }
+
+ private var secondaryForegroundColor: Color {
+ isSelected ? Color(nsColor: .white) : .secondary
+ }
+
+ var body: some View {
+ HStack(alignment: .center, spacing: 4) {
+ VStack(alignment: .leading, spacing: 4) {
+ HStack(alignment: .center, spacing: 4) {
+ Text(model.modelCapabilities?.name ?? model.modelId)
+ .foregroundColor(primaryForegroundColor)
+
+ Text(model.modelCapabilities?.name != nil ? model.modelId : "")
+ .foregroundColor(secondaryForegroundColor)
+ .font(.callout)
+
+ if model.isCustomModel {
+ Badge(
+ text: "Custom Model",
+ level: .info,
+ isSelected: isSelected
+ )
+ }
+ }
+
+ Group {
+ if let modelCapabilities = model.modelCapabilities,
+ modelCapabilities.toolCalling || modelCapabilities.vision {
+ HStack(spacing: 0) {
+ if modelCapabilities.toolCalling {
+ Text("Tools").help("Support Tool Calling")
+ }
+ if modelCapabilities.vision {
+ Text("・")
+ Text("Vision").help("Support Vision")
+ }
+ }
+ } else {
+ EmptyView()
+ }
+ }
+ .foregroundColor(secondaryForegroundColor)
+ }
+
+ Spacer()
+
+ // Show edit icon for custom model when selected or hovered
+ if model.isCustomModel {
+ Button(action: {
+ onEditRequested?(model)
+ }) {
+ Image(systemName: "gearshape")
+ }
+ .buttonStyle(HoverButtonStyle(
+ hoverColor: isSelected ? .white.opacity(0.1) : .hoverColor
+ ))
+ .foregroundColor(primaryForegroundColor)
+ .opacity((isSelected || isHovered) ? 1.0 : 0.0)
+ .padding(.horizontal, 12)
+ }
+
+ Toggle(" ", isOn: Binding(
+ // Space in toggle label ensures proper checkbox centering alignment
+ get: { model.isRegistered },
+ set: { newValue in
+ // Only save when user directly toggles the checkbox
+ Task {
+ do {
+ var newModelInfo = model
+ newModelInfo.isRegistered = newValue
+ try await dataManager.saveModel(newModelInfo)
+ } catch {
+ Logger.client.error("Failed to update model: \(error.localizedDescription)")
+ }
+ }
+ }
+ ))
+ .toggleStyle(.checkbox)
+ .labelStyle(.iconOnly)
+ .padding(.vertical, 4)
+ }
+ .padding(.leading, 36)
+ .padding(.trailing, 16)
+ .padding(.vertical, 4)
+ .contentShape(Rectangle())
+ .background(
+ isSelected ? Color(nsColor: .controlAccentColor) : Color.clear
+ )
+ .onTapGesture { onSelection() }
+ .onHover { hovering in
+ isHovered = hovering
+ }
+ }
+}
diff --git a/Core/Sources/HostApp/BYOKSettings/ModelSheet.swift b/Core/Sources/HostApp/BYOKSettings/ModelSheet.swift
new file mode 100644
index 00000000..4ce44c91
--- /dev/null
+++ b/Core/Sources/HostApp/BYOKSettings/ModelSheet.swift
@@ -0,0 +1,171 @@
+import GitHubCopilotService
+import SwiftUI
+import SharedUIComponents
+
+struct ModelSheet: View {
+ @ObservedObject var dataManager: BYOKModelManagerObservable
+ @Environment(\.dismiss) private var dismiss
+
+ @State private var modelId = ""
+ @State private var deploymentUrl = ""
+ @State private var apiKey = ""
+ @State private var customModelName = ""
+ @State private var supportToolCalling: Bool = true
+ @State private var supportVision: Bool = true
+
+ let provider: BYOKProvider
+ let existingModel: BYOKModelInfo?
+
+ // Computed property to determine if this is a per-model deployment provider
+ private var isPerModelDeployment: Bool {
+ provider.authType == .PerModelDeployment
+ }
+
+ // Computed property to determine if we're editing vs adding
+ private var isEditing: Bool {
+ existingModel != nil
+ }
+
+ var body: some View {
+ Form {
+ VStack(alignment: .center, spacing: 20) {
+ HStack(alignment: .center) {
+ Spacer()
+ Text("\(provider.title)").font(.headline)
+ Spacer()
+ AdaptiveHelpLink(action: openHelpLink)
+ }
+
+ VStack(alignment: .leading, spacing: 8) {
+ // Deployment/Model Name Section
+ TextFieldsContainer {
+ TextField(isPerModelDeployment ? "Deployment Name" : "Model ID", text: $modelId)
+ }
+
+ // Endpoint Section (only for per-model deployment)
+ if isPerModelDeployment {
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Endpoint")
+ .foregroundStyle(.secondary)
+ .font(.callout)
+ .padding(.horizontal, 8)
+
+ TextFieldsContainer {
+ TextField("Target URI", text: $deploymentUrl)
+
+ Divider()
+
+ SecureField("API Key", text: $apiKey)
+ }
+ }
+ }
+
+ // Optional Section
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Optional")
+ .foregroundStyle(.secondary)
+ .font(.callout)
+ .padding(.horizontal, 8)
+
+ TextFieldsContainer {
+ TextField("Display Name", text: $customModelName)
+ }
+
+ HStack(spacing: 16) {
+ Toggle("Support Tool Calling", isOn: $supportToolCalling)
+ .toggleStyle(CheckboxToggleStyle())
+ Toggle("Support Vision", isOn: $supportVision)
+ .toggleStyle(CheckboxToggleStyle())
+ }
+ .padding(.horizontal, 8)
+ .padding(.vertical, 6)
+ }
+ }
+
+ HStack(spacing: 8) {
+ Spacer()
+ Button("Cancel") { dismiss() }.buttonStyle(.bordered)
+ Button(isEditing ? "Save" : "Add") { saveModel() }
+ .buttonStyle(.borderedProminent)
+ .disabled(isFormInvalid)
+ }
+ }
+ .textFieldStyle(.plain)
+ .multilineTextAlignment(.trailing)
+ .padding(20)
+ }
+ .onAppear {
+ loadModelData()
+ }
+ }
+
+ private var isFormInvalid: Bool {
+ let modelIdEmpty = modelId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+
+ if isPerModelDeployment {
+ let deploymentUrlEmpty = deploymentUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ let apiKeyEmpty = apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ return modelIdEmpty || deploymentUrlEmpty || apiKeyEmpty
+ } else {
+ return modelIdEmpty
+ }
+ }
+
+ private func loadModelData() {
+ guard let model = existingModel else { return }
+
+ modelId = model.modelId
+ customModelName = model.modelCapabilities?.name ?? ""
+ supportToolCalling = model.modelCapabilities?.toolCalling ?? true
+ supportVision = model.modelCapabilities?.vision ?? true
+
+ if isPerModelDeployment {
+ deploymentUrl = model.deploymentUrl ?? ""
+ apiKey = dataManager
+ .filteredApiKeys(
+ for: provider,
+ modelId: modelId
+ ).first?.apiKey ?? ""
+ }
+ }
+
+ private func saveModel() {
+ Task {
+ do {
+ // Trim whitespace and newlines from all input fields
+ let trimmedModelId = modelId.trimmingCharacters(in: .whitespacesAndNewlines)
+ let trimmedDeploymentUrl = deploymentUrl.trimmingCharacters(in: .whitespacesAndNewlines)
+ let trimmedApiKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
+ let trimmedCustomModelName = customModelName.trimmingCharacters(in: .whitespacesAndNewlines)
+
+ let modelParams = BYOKModelInfo(
+ providerName: provider,
+ modelId: trimmedModelId,
+ isRegistered: existingModel?.isRegistered ?? true,
+ isCustomModel: true,
+ deploymentUrl: isPerModelDeployment ? trimmedDeploymentUrl : nil,
+ apiKey: isPerModelDeployment ? trimmedApiKey : nil,
+ modelCapabilities: BYOKModelCapabilities(
+ name: trimmedCustomModelName.isEmpty ? trimmedModelId : trimmedCustomModelName,
+ toolCalling: supportToolCalling,
+ vision: supportVision
+ )
+ )
+
+ if let originalModel = existingModel, trimmedModelId != originalModel.modelId {
+ // Delete existing model if the model ID has changed
+ try await dataManager.deleteModel(originalModel)
+ }
+
+ try await dataManager.saveModel(modelParams)
+ dismiss()
+ } catch {
+ dataManager.errorMessages[provider] = "Failed to \(isEditing ? "update" : "add") model: \(error.localizedDescription)"
+ }
+ }
+ }
+
+ private func openHelpLink() {
+ NSWorkspace.shared.open(URL(string: BYOKHelpLink)!)
+ }
+}
diff --git a/Core/Sources/HostApp/BYOKSettings/ProviderConfigView.swift b/Core/Sources/HostApp/BYOKSettings/ProviderConfigView.swift
new file mode 100644
index 00000000..194c4f91
--- /dev/null
+++ b/Core/Sources/HostApp/BYOKSettings/ProviderConfigView.swift
@@ -0,0 +1,311 @@
+import Client
+import GitHubCopilotService
+import Logger
+import SharedUIComponents
+import SwiftUI
+
+struct ModelConfig: Identifiable {
+ let id = UUID()
+ var name: String
+ var isSelected: Bool
+}
+
+struct BYOKProviderConfigView: View {
+ let provider: BYOKProvider
+ @ObservedObject var dataManager: BYOKModelManagerObservable
+ let onSheetRequested: (BYOKSheetType) -> Void
+ @Binding var isExpanded: Bool
+
+ @State private var selectedModelId: String? = nil
+ @State private var isSelectedCustomModel: Bool = false
+ @State private var showDeleteConfirmation: Bool = false
+ @State private var isSearchBarVisible: Bool = false
+ @State private var searchText: String = ""
+
+ @Environment(\.colorScheme) var colorScheme
+
+ private var hasApiKey: Bool { dataManager.hasApiKey(for: provider) }
+ private var hasModels: Bool { dataManager.hasModels(for: provider) }
+ private var allModels: [BYOKModelInfo] { dataManager.filteredModels(for: provider) }
+ private var filteredModels: [BYOKModelInfo] {
+ let base = allModels
+ let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ guard !trimmed.isEmpty else { return base }
+ return base.filter { model in
+ let modelIdMatch = model.modelId.lowercased().contains(trimmed)
+ let nameMatch = (model.modelCapabilities?.name ?? "").lowercased().contains(trimmed)
+ return modelIdMatch || nameMatch
+ }
+ }
+
+ private var isProviderEnabled: Bool { allModels.contains { $0.isRegistered } }
+ private var errorMessage: String? { dataManager.errorMessages[provider] }
+ private var deleteModelTooltip: String {
+ if let selectedModelId = selectedModelId {
+ if isSelectedCustomModel {
+ return "Delete this model from the list."
+ } else {
+ return "\(allModels.first(where: { $0.modelId == selectedModelId })?.modelCapabilities?.name ?? selectedModelId) is the default model from \(provider.title) and can’t be removed."
+ }
+ }
+ return "Select a model to delete."
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ ProviderHeaderRowView
+
+ if hasApiKey && isExpanded {
+ Group {
+ if !filteredModels.isEmpty {
+ ModelsListSection
+ } else if !allModels.isEmpty && !searchText.isEmpty {
+ VStack(spacing: 0) {
+ Divider()
+ Text("No models match \"\(searchText)\"")
+ .foregroundColor(.secondary)
+ .padding(.vertical, 8)
+ }
+ }
+ }
+ .padding(.vertical, 0)
+ .background(QuaternarySystemFillColor.opacity(0.75))
+ .transition(.opacity.combined(with: .scale(scale: 1, anchor: .top)))
+
+ FooterToolBar
+ }
+ }
+ .onChange(of: searchText) { _ in
+ // Clear selection if filtered out
+ if let selected = selectedModelId,
+ !filteredModels.contains(where: { $0.modelId == selected }) {
+ selectedModelId = nil
+ isSelectedCustomModel = false
+ }
+ }
+ .settingsContainerStyle(isExpanded: isExpanded)
+ }
+
+ // MARK: - UI Components
+
+ private var ProviderLabelView: some View {
+ 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 {
+ 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 }
+ }
+ },
+ 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)
+ }
+ )
+ }
+
+ @ViewBuilder
+ private var ConfiguredProviderActions: some View {
+ HStack(spacing: 8) {
+ if provider.authType == .GlobalApiKey && isExpanded {
+ CollapsibleSearchField(searchText: $searchText, isExpanded: $isSearchBarVisible)
+
+ Button(action: { Task {
+ await dataManager.listModelsWithFetch(providerName: provider)
+ }}) {
+ Image(systemName: "arrow.clockwise")
+ }
+ .buttonStyle(HoverButtonStyle())
+
+ Button(action: openAddApiKeySheetType) {
+ Image(systemName: "key")
+ }
+ .buttonStyle(HoverButtonStyle())
+
+ Button(action: { showDeleteConfirmation = true }) {
+ Image(systemName: "trash")
+ }
+ .confirmationDialog(
+ "Delete \(provider.title) API Key?",
+ isPresented: $showDeleteConfirmation
+
+ ) {
+ Button("Cancel", role: .cancel) { }
+ Button("Delete", role: .destructive) { deleteApiKey() }
+ } message: {
+ Text("This will remove all linked models and configurations. Still want to delete it?")
+ }
+ .buttonStyle(HoverButtonStyle())
+ }
+
+ Toggle("", isOn: Binding(
+ get: { isProviderEnabled },
+ set: { newValue in updateAllModels(isRegistered: newValue) }
+ ))
+ .toggleStyle(.switch)
+ .controlSize(.mini)
+ }
+ }
+
+ private var UnconfiguredProviderAction: some View {
+ Button(
+ provider.authType == .PerModelDeployment ? "Add Model" : "Add",
+ systemImage: "plus"
+ ) {
+ openAddApiKeySheetType()
+ }
+ }
+
+ private var ModelsListSection: some View {
+ LazyVStack(alignment: .leading, spacing: 0) {
+ ForEach(filteredModels, id: \.modelId) { model in
+ Divider()
+ ModelRowView(
+ model: model,
+ dataManager: dataManager,
+ isSelected: selectedModelId == model.modelId,
+ onSelection: {
+ selectedModelId = selectedModelId == model.modelId ? nil : model.modelId
+ isSelectedCustomModel = selectedModelId != nil && model.isCustomModel
+ },
+ onEditRequested: { model in
+ openEditModelSheet(for: model)
+ }
+ )
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+
+ private var FooterToolBar: some View {
+ VStack(spacing: 0) {
+ Divider()
+ HStack(spacing: 8) {
+ Button(action: openAddModelSheet) {
+ Image(systemName: "plus")
+ }
+ .foregroundColor(.primary)
+ .font(.title2)
+ .buttonStyle(.borderless)
+
+ Divider()
+
+ Group {
+ if isSelectedCustomModel {
+ Button(action: deleteSelectedModel) {
+ Image(systemName: "minus")
+ }
+ .buttonStyle(.borderless)
+ } else {
+ Image(systemName: "minus")
+ }
+ }
+ .font(.title2)
+ .foregroundColor(
+ isSelectedCustomModel ? .primary : Color(
+ nsColor: .quaternaryLabelColor
+ )
+ )
+ .help(deleteModelTooltip)
+
+ Spacer()
+ }
+ .frame(height: 20)
+ .padding(.horizontal, 8)
+ .padding(.vertical, 4)
+ .background(TertiarySystemFillColor)
+ }
+ .transition(.opacity.combined(with: .scale(scale: 1, anchor: .top)))
+ }
+
+ // MARK: - Actions
+
+ private func openAddApiKeySheetType() {
+ switch provider.authType {
+ case .GlobalApiKey:
+ onSheetRequested(.apiKey(provider))
+ case .PerModelDeployment:
+ onSheetRequested(.model(provider))
+ }
+ }
+
+ private func openAddModelSheet() {
+ onSheetRequested(.model(provider, nil)) // nil for adding new model
+ }
+
+ private func openEditModelSheet(for model: BYOKModelInfo) {
+ onSheetRequested(.model(provider, model)) // pass model for editing
+ }
+
+ private func deleteApiKey() {
+ Task {
+ do {
+ try await dataManager.deleteApiKey(providerName: provider)
+ } catch {
+ Logger.client.error("Failed to delete API key for \(provider.title): \(error)")
+ }
+ }
+ }
+
+ private func deleteSelectedModel() {
+ guard let selectedModelId = selectedModelId,
+ let selectedModel = allModels.first(where: { $0.modelId == selectedModelId }) else {
+ return
+ }
+
+ self.selectedModelId = nil
+ isSelectedCustomModel = false
+
+ Task {
+ do {
+ try await dataManager.deleteModel(selectedModel)
+ } catch {
+ Logger.client.error("Failed to delete model for \(provider.title): \(error)")
+ }
+ }
+ }
+
+ private func updateAllModels(isRegistered: Bool) {
+ Task {
+ do {
+ try await dataManager.updateAllModels(providerName: provider, isRegistered: isRegistered)
+ } catch {
+ Logger.client.error("Failed to register models for \(provider.title): \(error)")
+ }
+ }
+ }
+}
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/General.swift b/Core/Sources/HostApp/General.swift
index 80bfcf5d..92d78a25 100644
--- a/Core/Sources/HostApp/General.swift
+++ b/Core/Sources/HostApp/General.swift
@@ -8,20 +8,29 @@ import XPCShared
import Logger
@Reducer
-struct General {
+public struct General {
@ObservableState
- struct State: Equatable {
+ public struct State: Equatable {
var xpcServiceVersion: String?
+ var xpcCLSVersion: String?
var isAccessibilityPermissionGranted: ObservedAXStatus = .unknown
+ var isExtensionPermissionGranted: ExtensionPermissionStatus = .unknown
+ var xpcServiceAuthStatus: AuthStatus = .init(status: .unknown)
var isReloading = false
}
- enum Action: Equatable {
+ public enum Action: Equatable {
case appear
case setupLaunchAgentIfNeeded
case openExtensionManager
case reloadStatus
- case finishReloading(xpcServiceVersion: String, permissionGranted: ObservedAXStatus)
+ case finishReloading(
+ xpcServiceVersion: String,
+ xpcCLSVersion: String?,
+ axStatus: ObservedAXStatus,
+ extensionStatus: ExtensionPermissionStatus,
+ authStatus: AuthStatus
+ )
case failedReloading
case retryReloading
}
@@ -30,7 +39,7 @@ struct General {
struct ReloadStatusCancellableId: Hashable {}
- var body: some ReducerOf {
+ public var body: some ReducerOf {
Reduce { state, action in
switch action {
case .appear:
@@ -53,7 +62,7 @@ struct General {
.setupLaunchAgentForTheFirstTimeIfNeeded()
} catch {
Logger.ui.error("Failed to setup launch agent. \(error.localizedDescription)")
- toast(error.localizedDescription, .error)
+ toast("Operation failed: permission denied. This may be due to missing background permissions.", .error)
}
await send(.reloadStatus)
}
@@ -84,9 +93,15 @@ struct General {
let xpcServiceVersion = try await service.getXPCServiceVersion().version
let isAccessibilityPermissionGranted = try await service
.getXPCServiceAccessibilityPermission()
+ let isExtensionPermissionGranted = try await service.getXPCServiceExtensionPermission()
+ let xpcServiceAuthStatus = try await service.getXPCServiceAuthStatus() ?? .init(status: .unknown)
+ let xpcCLSVersion = try await service.getXPCCLSVersion()
await send(.finishReloading(
xpcServiceVersion: xpcServiceVersion,
- permissionGranted: isAccessibilityPermissionGranted
+ xpcCLSVersion: xpcCLSVersion,
+ axStatus: isAccessibilityPermissionGranted,
+ extensionStatus: isExtensionPermissionGranted,
+ authStatus: xpcServiceAuthStatus
))
} else {
toast("Launching service app.", .info)
@@ -96,7 +111,7 @@ struct General {
} catch let error as XPCCommunicationBridgeError {
Logger.ui.error("Failed to reach communication bridge. \(error.localizedDescription)")
toast(
- "Failed to reach communication bridge. \(error.localizedDescription)",
+ "Unable to connect to the communication bridge. The helper application didn't respond. This may be due to missing background permissions.",
.error
)
await send(.failedReloading)
@@ -107,9 +122,12 @@ struct General {
}
}.cancellable(id: ReloadStatusCancellableId(), cancelInFlight: true)
- case let .finishReloading(version, granted):
+ case let .finishReloading(version, clsVersion, axStatus, extensionStatus, authStatus):
state.xpcServiceVersion = version
- state.isAccessibilityPermissionGranted = granted
+ state.isAccessibilityPermissionGranted = axStatus
+ state.isExtensionPermissionGranted = extensionStatus
+ state.xpcServiceAuthStatus = authStatus
+ state.xpcCLSVersion = clsVersion
state.isReloading = false
return .none
diff --git a/Core/Sources/HostApp/GeneralSettings/AppInfoView.swift b/Core/Sources/HostApp/GeneralSettings/AppInfoView.swift
index 837f3047..0cf5e8af 100644
--- a/Core/Sources/HostApp/GeneralSettings/AppInfoView.swift
+++ b/Core/Sources/HostApp/GeneralSettings/AppInfoView.swift
@@ -1,6 +1,5 @@
import ComposableArchitecture
import GitHubCopilotService
-import GitHubCopilotViewModel
import SwiftUI
struct AppInfoView: View {
@@ -15,7 +14,6 @@ struct AppInfoView: View {
@Environment(\.toast) var toast
@StateObject var settings = Settings()
- @StateObject var viewModel: GitHubCopilotViewModel
@State var appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
@State var automaticallyCheckForUpdates: Bool?
@@ -23,53 +21,54 @@ struct AppInfoView: View {
let store: StoreOf
var body: some View {
- HStack(alignment: .center, spacing: 16) {
- let appImage = if let nsImage = NSImage(named: "AppIcon") {
- Image(nsImage: nsImage)
- } else {
- Image(systemName: "app")
- }
- appImage
- .resizable()
- .frame(width: 110, height: 110)
- VStack(alignment: .leading, spacing: 8) {
- HStack {
- Text(Bundle.main.object(forInfoDictionaryKey: "HOST_APP_NAME") as? String ?? "GitHub Copilot for Xcode")
- .font(.title)
- Text("(\(appVersion ?? ""))")
- .font(.title)
+ WithPerceptionTracking {
+ HStack(alignment: .center, spacing: 16) {
+ let appImage = if let nsImage = NSImage(named: "AppIcon") {
+ Image(nsImage: nsImage)
+ } else {
+ Image(systemName: "app")
}
- Text("Language Server Version: \(viewModel.version ?? "Loading...")")
- Button(action: {
- updateChecker.checkForUpdates()
- }) {
- HStack(spacing: 2) {
- Text("Check for Updates")
+ appImage
+ .resizable()
+ .frame(width: 110, height: 110)
+ VStack(alignment: .leading, spacing: 8) {
+ HStack {
+ Text(Bundle.main.object(forInfoDictionaryKey: "HOST_APP_NAME") as? String ?? "GitHub Copilot for Xcode")
+ .font(.title)
+ Text("(\(appVersion ?? ""))")
+ .font(.title)
}
- }
- HStack {
- Toggle(isOn: .init(
- get: { automaticallyCheckForUpdates ?? updateChecker.getAutomaticallyChecksForUpdates() },
- set: { updateChecker.setAutomaticallyChecksForUpdates($0); automaticallyCheckForUpdates = $0 }
- )) {
- Text("Automatically Check for Updates")
+ Text("Language Server Version: \(store.xpcCLSVersion ?? "Loading...")")
+ Button(action: {
+ updateChecker.checkForUpdates()
+ }) {
+ HStack(spacing: 2) {
+ Text("Check for Updates")
+ }
}
-
- Toggle(isOn: $settings.installPrereleases) {
- Text("Install pre-releases")
+ HStack {
+ Toggle(isOn: .init(
+ get: { automaticallyCheckForUpdates ?? updateChecker.getAutomaticallyChecksForUpdates() },
+ set: { updateChecker.setAutomaticallyChecksForUpdates($0); automaticallyCheckForUpdates = $0 }
+ )) {
+ Text("Automatically Check for Updates")
+ }
+
+ Toggle(isOn: $settings.installPrereleases) {
+ Text("Install pre-releases")
+ }
}
}
+ Spacer()
}
- Spacer()
+ .padding(.horizontal, 2)
+ .padding(.vertical, 15)
}
- .padding(.horizontal, 2)
- .padding(.vertical, 15)
}
}
#Preview {
AppInfoView(
- viewModel: GitHubCopilotViewModel.shared,
store: .init(initialState: .init(), reducer: { General() })
)
}
diff --git a/Core/Sources/HostApp/GeneralSettings/CopilotConnectionView.swift b/Core/Sources/HostApp/GeneralSettings/CopilotConnectionView.swift
index aeb8bd70..81f7b9fc 100644
--- a/Core/Sources/HostApp/GeneralSettings/CopilotConnectionView.swift
+++ b/Core/Sources/HostApp/GeneralSettings/CopilotConnectionView.swift
@@ -1,6 +1,7 @@
import ComposableArchitecture
import GitHubCopilotViewModel
import SwiftUI
+import Client
struct CopilotConnectionView: View {
@AppStorage("username") var username: String = ""
@@ -18,23 +19,36 @@ struct CopilotConnectionView: View {
}
}
}
+
+ var accountStatusString: String {
+ switch store.xpcServiceAuthStatus.status {
+ case .loggedIn:
+ return "Active"
+ case .notLoggedIn:
+ return "Not Signed In"
+ case .notAuthorized:
+ return "No Subscription"
+ case .unknown:
+ return "Loading..."
+ }
+ }
var accountStatus: some View {
SettingsButtonRow(
title: "GitHub Account Status Permissions",
- subtitle: "GitHub Account: \(viewModel.status?.description ?? "Loading...")"
+ subtitle: "GitHub Account: \(accountStatusString)"
) {
if viewModel.isRunningAction || viewModel.waitingForSignIn {
ProgressView().controlSize(.small)
}
Button("Refresh Connection") {
- viewModel.checkStatus()
+ store.send(.reloadStatus)
}
if viewModel.waitingForSignIn {
Button("Cancel") {
viewModel.cancelWaiting()
}
- } else if viewModel.status == .notSignedIn {
+ } else if store.xpcServiceAuthStatus.status == .notLoggedIn {
Button("Log in to GitHub") {
viewModel.signIn()
}
@@ -43,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 \
@@ -54,21 +71,31 @@ struct CopilotConnectionView: View {
""")
}
}
- if viewModel.status == .ok || viewModel.status == .alreadySignedIn ||
- viewModel.status == .notAuthorized
- {
- Button("Log Out from GitHub") { viewModel.signOut()
- viewModel.isSignInAlertPresented = false
+ if store.xpcServiceAuthStatus.status == .loggedIn || store.xpcServiceAuthStatus.status == .notAuthorized {
+ Button("Log Out from GitHub") {
+ Task {
+ viewModel.signOut()
+ viewModel.isSignInAlertPresented = false
+ let service = try getService()
+ do {
+ try await service.signOutAllGitHubCopilotService()
+ } catch {
+ toast(error.localizedDescription, .error)
+ }
+ }
}
}
}
}
var connection: some View {
- SettingsSection(title: "Account Settings", showWarning: viewModel.status == .notAuthorized) {
+ SettingsSection(
+ title: "Account Settings",
+ showWarning: store.xpcServiceAuthStatus.status == .notAuthorized
+ ) {
accountStatus
Divider()
- if viewModel.status == .notAuthorized {
+ if store.xpcServiceAuthStatus.status == .notAuthorized {
SettingsLink(
url: "https://github.com/features/copilot/plans",
title: "Enable powerful AI features for free with the GitHub Copilot Free plan"
@@ -80,6 +107,9 @@ struct CopilotConnectionView: View {
title: "GitHub Copilot Account Settings"
)
}
+ .onReceive(DistributedNotificationCenter.default().publisher(for: .authStatusDidChange)) { _ in
+ store.send(.reloadStatus)
+ }
}
var copilotResources: some View {
@@ -90,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 2ce752ae..19418245 100644
--- a/Core/Sources/HostApp/GeneralSettings/GeneralSettingsView.swift
+++ b/Core/Sources/HostApp/GeneralSettings/GeneralSettingsView.swift
@@ -1,10 +1,12 @@
import ComposableArchitecture
import SwiftUI
+import SharedUIComponents
struct GeneralSettingsView: View {
@AppStorage(\.extensionPermissionShown) var extensionPermissionShown: Bool
@AppStorage(\.quitXPCServiceOnXcodeAndAppQuit) var quitXPCServiceOnXcodeAndAppQuit: Bool
@State private var shouldPresentExtensionPermissionAlert = false
+ @State private var shouldShowRestartXcodeAlert = false
let store: StoreOf
@@ -13,12 +15,53 @@ struct GeneralSettingsView: View {
case .granted:
return "Granted"
case .notGranted:
- return "Not Granted. Required to run. Click to open System Preferences."
+ return "Enable accessibility in system preferences"
case .unknown:
return ""
}
}
+ var extensionPermissionSubtitle: any View {
+ switch store.isExtensionPermissionGranted {
+ case .notGranted:
+ return HStack(spacing: 0) {
+ Text("Enable ")
+ Text(
+ "Extensions \(Image(systemName: "puzzlepiece.extension.fill")) → Xcode Source Editor \(Image(systemName: "info.circle")) → GitHub Copilot for Xcode"
+ )
+ .bold()
+ .foregroundStyle(.primary)
+ Text(" for faster and full-featured code completion.")
+ }
+ case .disabled:
+ return Text("Quit and restart Xcode to enable extension")
+ case .granted:
+ return Text("Granted")
+ case .unknown:
+ return Text("")
+ }
+ }
+
+ var extensionPermissionBadge: BadgeItem? {
+ switch store.isExtensionPermissionGranted {
+ case .notGranted:
+ return .init(text: "Not Granted", level: .danger)
+ case .disabled:
+ return .init(text: "Disabled", level: .danger)
+ default:
+ return nil
+ }
+ }
+
+ var extensionPermissionAction: () -> Void {
+ switch store.isExtensionPermissionGranted {
+ case .disabled:
+ return { shouldShowRestartXcodeAlert = true }
+ default:
+ return NSWorkspace.openXcodeExtensionsPreferences
+ }
+ }
+
var body: some View {
SettingsSection(title: "General") {
SettingsToggle(
@@ -29,45 +72,62 @@ struct GeneralSettingsView: View {
SettingsLink(
url: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility",
title: "Accessibility Permission",
- subtitle: accessibilityPermissionSubtitle
+ subtitle: accessibilityPermissionSubtitle,
+ badge: store.isAccessibilityPermissionGranted == .notGranted ?
+ .init(
+ text: "Not Granted",
+ level: .danger
+ ) : nil
)
Divider()
SettingsLink(
- url: "x-apple.systempreferences:com.apple.ExtensionsPreferences",
+ action: extensionPermissionAction,
title: "Extension Permission",
- subtitle: """
- Check for GitHub Copilot in Xcode's Editor menu. \
- Restart Xcode if greyed out.
- """
+ subtitle: extensionPermissionSubtitle,
+ badge: extensionPermissionBadge
)
} 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(
"Enable Extension Permission",
isPresented: $shouldPresentExtensionPermissionAlert
) {
- Button("Open System Preferences", action: {
- let url = "x-apple.systempreferences:com.apple.ExtensionsPreferences"
+ Button(
+ "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)!)
- }).keyboardShortcut(.defaultAction)
+ })
Button("Close", role: .cancel, action: {})
} message: {
- Text("Enable GitHub Copilot under Xcode Source Editor extensions")
+ Text("To enable faster and full-featured code completion, navigate to:\nExtensions → Xcode Source Editor → GitHub Copilot for Xcode.")
}
.task {
if extensionPermissionShown { return }
extensionPermissionShown = true
shouldPresentExtensionPermissionAlert = true
}
+ .alert(
+ "Restart Xcode?",
+ isPresented: $shouldShowRestartXcodeAlert
+ ) {
+ 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/GeneralView.swift b/Core/Sources/HostApp/GeneralView.swift
index 7ba62833..e80c9491 100644
--- a/Core/Sources/HostApp/GeneralView.swift
+++ b/Core/Sources/HostApp/GeneralView.swift
@@ -7,24 +7,25 @@ struct GeneralView: View {
@StateObject private var viewModel = GitHubCopilotViewModel.shared
var body: some View {
- ScrollView {
- VStack(alignment: .leading, spacing: 0) {
- generalView.padding(20)
- Divider()
- rightsView.padding(20)
+ WithPerceptionTracking {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 0) {
+ generalView.padding(20)
+ Divider()
+ rightsView.padding(20)
+ }
+ .frame(maxWidth: .infinity)
+ }
+ .task {
+ if isPreview { return }
+ await store.send(.appear).finish()
}
- .frame(maxWidth: .infinity)
- }
- .task {
- if isPreview { return }
- viewModel.checkStatus()
- await store.send(.appear).finish()
}
}
private var generalView: some View {
VStack(alignment: .leading, spacing: 30) {
- AppInfoView(viewModel: viewModel, store: store)
+ AppInfoView(store: store)
GeneralSettingsView(store: store)
CopilotConnectionView(viewModel: viewModel, store: store)
}
diff --git a/Core/Sources/HostApp/HandleToast.swift b/Core/Sources/HostApp/HandleToast.swift
index 564fdada..8f5d7779 100644
--- a/Core/Sources/HostApp/HandleToast.swift
+++ b/Core/Sources/HostApp/HandleToast.swift
@@ -17,16 +17,7 @@ struct ToastHandler: View {
if let n = message.namespace, n != namespace {
EmptyView()
} else {
- message.content
- .foregroundColor(.white)
- .padding(8)
- .background({
- switch message.type {
- case .info: return Color.accentColor
- case .error: return Color(nsColor: .systemRed)
- case .warning: return Color(nsColor: .systemOrange)
- }
- }() as Color, in: RoundedRectangle(cornerRadius: 8))
+ NotificationView(message: message)
.shadow(color: Color.black.opacity(0.2), radius: 4)
}
}
@@ -41,8 +32,8 @@ extension View {
@Dependency(\.toastController) var toastController
return overlay(alignment: .bottom) {
ToastHandler(toastController: toastController, namespace: namespace)
- }.environment(\.toast) { [toastController] content, type in
- toastController.toast(content: content, type: type, namespace: namespace)
+ }.environment(\.toast) { [toastController] content, level in
+ toastController.toast(content: content, level: level, namespace: namespace)
}
}
}
diff --git a/Core/Sources/HostApp/HostApp.swift b/Core/Sources/HostApp/HostApp.swift
index fc03d87b..ba3c3da7 100644
--- a/Core/Sources/HostApp/HostApp.swift
+++ b/Core/Sources/HostApp/HostApp.swift
@@ -7,16 +7,57 @@ extension KeyboardShortcuts.Name {
static let showHideWidget = Self("ShowHideWidget")
}
+public enum TabIndex: Int, CaseIterable {
+ case general = 0
+ case advanced = 1
+ case tools = 2
+ case byok = 3
+
+ var title: String {
+ switch self {
+ case .general: return "General"
+ case .advanced: return "Advanced"
+ case .tools: return "Tools"
+ case .byok: return "Models"
+ }
+ }
+
+ var image: String {
+ switch self {
+ case .general: return "CopilotLogo"
+ case .advanced: return "gearshape.2.fill"
+ case .tools: return "wrench.and.screwdriver.fill"
+ case .byok: return "Model"
+ }
+ }
+
+ var isSystemImage: Bool {
+ switch self {
+ case .general, .byok: return false
+ default: return true
+ }
+ }
+}
+
+public enum ToolsSubTab: String, CaseIterable, Identifiable {
+ case MCP, BuiltIn, AutoApprove
+ public var id: Self { self }
+}
+
@Reducer
-struct HostApp {
+public struct HostApp {
@ObservableState
- struct State: Equatable {
+ public struct State: Equatable {
var general = General.State()
+ public var activeTabIndex: TabIndex = .general
+ public var activeToolsSubTab: ToolsSubTab = .MCP
}
- enum Action: Equatable {
+ public enum Action: Equatable {
case appear
case general(General.Action)
+ case setActiveTab(TabIndex)
+ case setActiveToolsSubTab(ToolsSubTab)
}
@Dependency(\.toast) var toast
@@ -25,18 +66,26 @@ struct HostApp {
KeyboardShortcuts.userDefaults = .shared
}
- var body: some ReducerOf {
+ public var body: some ReducerOf {
Scope(state: \.general, action: /Action.general) {
General()
}
- Reduce { _, action in
+ Reduce { state, action in
switch action {
case .appear:
return .none
case .general:
return .none
+
+ case .setActiveTab(let index):
+ state.activeTabIndex = index
+ return .none
+
+ case .setActiveToolsSubTab(let tab):
+ state.activeToolsSubTab = tab
+ return .none
}
}
}
@@ -66,5 +115,3 @@ extension DependencyValues {
set { self[UserDefaultsDependencyKey.self] = newValue }
}
}
-
-
diff --git a/Core/Sources/HostApp/LaunchAgentManager.swift b/Core/Sources/HostApp/LaunchAgentManager.swift
index ee031cb5..ba8a4126 100644
--- a/Core/Sources/HostApp/LaunchAgentManager.swift
+++ b/Core/Sources/HostApp/LaunchAgentManager.swift
@@ -1,7 +1,7 @@
import Foundation
import LaunchAgentManager
-extension LaunchAgentManager {
+public extension LaunchAgentManager {
init() {
self.init(
serviceIdentifier: Bundle.main
diff --git a/Core/Sources/HostApp/SharedComponents/Badge.swift b/Core/Sources/HostApp/SharedComponents/Badge.swift
new file mode 100644
index 00000000..7c0b2e03
--- /dev/null
+++ b/Core/Sources/HostApp/SharedComponents/Badge.swift
@@ -0,0 +1,121 @@
+import SwiftUI
+
+struct BadgeItem {
+ enum Level: String, Equatable {
+ case warning = "Warning"
+ case danger = "Danger"
+ case info = "Info"
+ }
+
+ let text: String
+ let level: Level
+ let icon: String?
+ let isSelected: Bool
+ let tooltip: String?
+
+ 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, 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 {
+ HStack(alignment: .center, spacing: 2) {
+ if let icon = icon {
+ Image(systemName: icon)
+ .font(.caption2)
+ .padding(.vertical, 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)
+ .foregroundColor(
+ level == .info ? Color(nsColor: isSelected ? .white : .secondaryLabelColor)
+ : Color("\(level.rawValue)ForegroundColor")
+ )
+ .background(
+ level == .info ? Color(nsColor: .clear)
+ : Color("\(level.rawValue)BackgroundColor"),
+ in: RoundedRectangle(
+ cornerRadius: 9999,
+ style: .circular
+ )
+ )
+ .overlay(
+ RoundedRectangle(
+ cornerRadius: 9999,
+ style: .circular
+ )
+ .stroke(
+ level == .info ? Color(nsColor: isSelected ? .white : .tertiaryLabelColor)
+ : Color("\(level.rawValue)StrokeColor"),
+ 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/BorderedProminentWhiteButtonStyle.swift b/Core/Sources/HostApp/SharedComponents/BorderedProminentWhiteButtonStyle.swift
new file mode 100644
index 00000000..7cc5db2a
--- /dev/null
+++ b/Core/Sources/HostApp/SharedComponents/BorderedProminentWhiteButtonStyle.swift
@@ -0,0 +1,28 @@
+import SwiftUI
+
+extension ButtonStyle where Self == BorderedProminentWhiteButtonStyle {
+ static var borderedProminentWhite: BorderedProminentWhiteButtonStyle {
+ BorderedProminentWhiteButtonStyle()
+ }
+}
+
+public struct BorderedProminentWhiteButtonStyle: ButtonStyle {
+ @Environment(\.colorScheme) var colorScheme
+
+ public func makeBody(configuration: Configuration) -> some View {
+ configuration.label
+ .padding(.leading, 4)
+ .padding(.trailing, 8)
+ .padding(.vertical, 0)
+ .frame(height: 22, alignment: .leading)
+ .foregroundColor(colorScheme == .dark ? .white : .primary)
+ .background(
+ colorScheme == .dark ? Color(red: 0.43, green: 0.43, blue: 0.44) : .white
+ )
+ .cornerRadius(5)
+ .overlay(
+ RoundedRectangle(cornerRadius: 5).stroke(.clear, lineWidth: 1)
+ )
+ }
+}
+
diff --git a/Core/Sources/HostApp/SharedComponents/CardGroupBoxStyle.swift b/Core/Sources/HostApp/SharedComponents/CardGroupBoxStyle.swift
new file mode 100644
index 00000000..7ab60d87
--- /dev/null
+++ b/Core/Sources/HostApp/SharedComponents/CardGroupBoxStyle.swift
@@ -0,0 +1,30 @@
+import SwiftUI
+import SharedUIComponents
+
+public struct CardGroupBoxStyle: GroupBoxStyle {
+ public var backgroundColor: Color
+ 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(.vertical, 12)
+ .padding(.horizontal, 20)
+ .frame(maxWidth: .infinity, alignment: .topLeading)
+ .background(backgroundColor)
+ .cornerRadius(12)
+ .overlay(
+ RoundedRectangle(cornerRadius: 12)
+ .inset(by: 0.5)
+ .stroke(borderColor, lineWidth: 1)
+ )
+ }
+}
diff --git a/Core/Sources/HostApp/SharedComponents/DebouncedBinding.swift b/Core/Sources/HostApp/SharedComponents/DebouncedBinding.swift
deleted file mode 100644
index 6b4224b2..00000000
--- a/Core/Sources/HostApp/SharedComponents/DebouncedBinding.swift
+++ /dev/null
@@ -1,25 +0,0 @@
-import Combine
-import SwiftUI
-
-class DebouncedBinding {
- private let subject = PassthroughSubject()
- private let cancellable: AnyCancellable
- private let wrappedBinding: Binding
-
- init(_ binding: Binding, handler: @escaping (T) -> Void) {
- self.wrappedBinding = binding
- self.cancellable = subject
- .debounce(for: .seconds(1.0), scheduler: RunLoop.main)
- .sink { handler($0) }
- }
-
- var binding: Binding {
- return Binding(
- get: { self.wrappedBinding.wrappedValue },
- set: {
- self.wrappedBinding.wrappedValue = $0
- self.subject.send($0)
- }
- )
- }
-}
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/SettingsButtonRow.swift b/Core/Sources/HostApp/SharedComponents/SettingsButtonRow.swift
index fa35afb7..2b583302 100644
--- a/Core/Sources/HostApp/SharedComponents/SettingsButtonRow.swift
+++ b/Core/Sources/HostApp/SharedComponents/SettingsButtonRow.swift
@@ -1,4 +1,5 @@
import SwiftUI
+import Perception
struct SettingsButtonRow: View {
let title: String
@@ -6,20 +7,22 @@ struct SettingsButtonRow: View {
@ViewBuilder let content: () -> Content
var body: some View {
- HStack(alignment: .center, spacing: 8) {
- VStack(alignment: .leading) {
- Text(title)
- .font(.body)
- if let subtitle = subtitle {
- Text(subtitle)
- .font(.footnote)
+ WithPerceptionTracking{
+ HStack(alignment: .center, spacing: 8) {
+ VStack(alignment: .leading) {
+ Text(title)
+ .font(.body)
+ if let subtitle = subtitle {
+ Text(subtitle)
+ .font(.footnote)
+ }
}
+ Spacer()
+ content()
}
- Spacer()
- content()
+ .foregroundStyle(.primary)
+ .padding(10)
}
- .foregroundStyle(.primary)
- .padding(10)
}
}
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/SettingsLink.swift b/Core/Sources/HostApp/SharedComponents/SettingsLink.swift
index b3c00cf3..32fb296d 100644
--- a/Core/Sources/HostApp/SharedComponents/SettingsLink.swift
+++ b/Core/Sources/HostApp/SharedComponents/SettingsLink.swift
@@ -1,33 +1,75 @@
import SwiftUI
struct SettingsLink: View {
- let url: URL
+ let action: ()->Void
let title: String
- let subtitle: String?
+ let subtitle: AnyView?
+ let badge: BadgeItem?
- init(_ url: URL, title: String, subtitle: String? = nil) {
- self.url = url
+ init(
+ action: @escaping ()->Void,
+ title: String,
+ subtitle: Subtitle?,
+ badge: BadgeItem? = nil
+ ) {
+ self.action = action
self.title = title
- self.subtitle = subtitle
+ self.subtitle = subtitle.map { AnyView($0) }
+ self.badge = badge
+ }
+
+ init(
+ _ url: URL,
+ title: String,
+ subtitle: String? = nil,
+ badge: BadgeItem? = nil
+ ) {
+ self.init(
+ action: { NSWorkspace.shared.open(url) },
+ title: title,
+ subtitle: subtitle.map { Text($0) },
+ badge: badge
+ )
}
- init(url: String, title: String, subtitle: String? = nil) {
- self.init(URL(string: url)!, title: title, subtitle: subtitle)
+ init(url: String, title: String, subtitle: String? = nil, badge: BadgeItem? = nil) {
+ self.init(
+ URL(string: url)!,
+ title: title,
+ subtitle: subtitle,
+ badge: badge
+ )
+ }
+
+ init(url: String, title: String, subtitle: Subtitle?, badge: BadgeItem? = nil) {
+ self.init(
+ action: { NSWorkspace.shared.open(URL(string: url)!) },
+ title: title,
+ subtitle: subtitle,
+ badge: badge
+ )
}
var body: some View {
- Link(destination: url) {
- VStack(alignment: .leading) {
- Text(title)
- .font(.body)
- if let subtitle = subtitle {
- Text(subtitle)
- .font(.footnote)
+ Button(action: action) {
+ HStack{
+ VStack(alignment: .leading) {
+ HStack{
+ Text(title).font(.body)
+ if let badge = self.badge {
+ Badge(badgeItem: badge)
+ }
+ }
+ if let subtitle = subtitle {
+ subtitle.font(.footnote)
+ }
}
+ Spacer()
+ Image(systemName: "chevron.right")
}
- Spacer()
- Image(systemName: "chevron.right")
+ .contentShape(Rectangle()) // This makes the entire HStack clickable
}
+ .buttonStyle(.plain)
.foregroundStyle(.primary)
.padding(10)
}
@@ -37,6 +79,7 @@ struct SettingsLink: View {
SettingsLink(
url: "https://example.com",
title: "Example",
- subtitle: "This is an example"
+ subtitle: "This is an example",
+ badge: .init(text: "Not Granted", level: .danger)
)
}
diff --git a/Core/Sources/HostApp/SharedComponents/SettingsSection.swift b/Core/Sources/HostApp/SharedComponents/SettingsSection.swift
index 1526e802..007eeb15 100644
--- a/Core/Sources/HostApp/SharedComponents/SettingsSection.swift
+++ b/Core/Sources/HostApp/SharedComponents/SettingsSection.swift
@@ -1,4 +1,5 @@
import SwiftUI
+import Perception
struct SettingsSection: View {
let title: String
@@ -15,31 +16,33 @@ struct SettingsSection: View {
}
var body: some View {
- VStack(alignment: .leading, spacing: 10) {
- Text(title)
- .bold()
- .padding(.horizontal, 10)
- if showWarning {
- HStack{
- Text("GitHub Copilot features are disabled. Please [check your subscription](https://github.com/settings/copilot) to access them.")
- .foregroundColor(Color("WarningForegroundColor"))
- .padding(4)
- Spacer()
+ WithPerceptionTracking{
+ VStack(alignment: .leading, spacing: 10) {
+ Text(title)
+ .bold()
+ .padding(.horizontal, 10)
+ if showWarning {
+ HStack{
+ Text("GitHub Copilot features are disabled. Please [check your subscription](https://github.com/settings/copilot) to access them.")
+ .foregroundColor(Color("WarningForegroundColor"))
+ .padding(4)
+ Spacer()
+ }
+ .background(Color("WarningBackgroundColor"))
+ .overlay(
+ RoundedRectangle(cornerRadius: 3)
+ .stroke(Color("WarningStrokeColor"), lineWidth: 1)
+ )
}
- .background(Color("WarningBackgroundColor"))
- .overlay(
- RoundedRectangle(cornerRadius: 3)
- .stroke(Color("WarningStrokeColor"), lineWidth: 1)
- )
- }
- VStack(alignment: .leading, spacing: 0) {
- content()
+ VStack(alignment: .leading, spacing: 0) {
+ content()
+ }
+ .background(Color.gray.opacity(0.1))
+ .cornerRadius(8)
+ footer()
}
- .background(Color.gray.opacity(0.1))
- .cornerRadius(8)
- footer()
+ .frame(maxWidth: .infinity, alignment: .leading)
}
- .frame(maxWidth: .infinity, alignment: .leading)
}
}
diff --git a/Core/Sources/HostApp/SharedComponents/SettingsTextField.swift b/Core/Sources/HostApp/SharedComponents/SettingsTextField.swift
index 580ef886..ae135ee5 100644
--- a/Core/Sources/HostApp/SharedComponents/SettingsTextField.swift
+++ b/Core/Sources/HostApp/SharedComponents/SettingsTextField.swift
@@ -4,31 +4,47 @@ struct SettingsTextField: View {
let title: String
let prompt: String
@Binding var text: String
-
- var body: some View {
- Form {
- TextField(text: $text, prompt: Text(prompt)) {
- Text(title)
- }
- .textFieldStyle(PlainTextFieldStyle())
- .multilineTextAlignment(.trailing)
- }
- .padding(10)
+ let isSecure: Bool
+
+ @State private var localText: String = ""
+ @State private var debounceTimer: Timer?
+
+ var onDebouncedChange: ((String) -> Void)?
+
+ init(title: String, prompt: String, text: Binding, isSecure: Bool = false, onDebouncedChange: ((String) -> Void)? = nil) {
+ self.title = title
+ self.prompt = prompt
+ self._text = text
+ self.isSecure = isSecure
+ self.onDebouncedChange = onDebouncedChange
+ self._localText = State(initialValue: text.wrappedValue)
}
-}
-
-struct SettingsSecureField: View {
- let title: String
- let prompt: String
- @Binding var text: String
var body: some View {
Form {
- SecureField(text: $text, prompt: Text(prompt)) {
- Text(title)
+ Group {
+ if isSecure {
+ SecureField(text: $localText, prompt: Text(prompt)) {
+ Text(title)
+ }
+ } else {
+ TextField(text: $localText, prompt: Text(prompt)) {
+ Text(title)
+ }
+ }
}
.textFieldStyle(.plain)
.multilineTextAlignment(.trailing)
+ .onChange(of: localText) { newValue in
+ text = newValue
+ debounceTimer?.invalidate()
+ debounceTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { _ in
+ onDebouncedChange?(newValue)
+ }
+ }
+ .onAppear {
+ localText = text
+ }
}
.padding(10)
}
@@ -42,10 +58,11 @@ struct SettingsSecureField: View {
text: .constant("")
)
Divider()
- SettingsSecureField(
+ SettingsTextField(
title: "Password",
prompt: "pass",
- text: .constant("")
+ text: .constant(""),
+ isSecure: true
)
}
.padding(.vertical, 10)
diff --git a/Core/Sources/HostApp/SharedComponents/SettingsToggle.swift b/Core/Sources/HostApp/SharedComponents/SettingsToggle.swift
index af681465..a3dc805d 100644
--- a/Core/Sources/HostApp/SharedComponents/SettingsToggle.swift
+++ b/Core/Sources/HostApp/SharedComponents/SettingsToggle.swift
@@ -1,17 +1,43 @@
import SwiftUI
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(10)
+ .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 0d3f0a87..c4a372cd 100644
--- a/Core/Sources/HostApp/TabContainer.swift
+++ b/Core/Sources/HostApp/TabContainer.swift
@@ -5,24 +5,36 @@ import LaunchAgentManager
import SwiftUI
import Toast
import UpdateChecker
+import Client
+import Logger
+import Combine
@MainActor
-let hostAppStore: StoreOf = .init(initialState: .init(), reducer: { HostApp() })
+public let hostAppStore: StoreOf = .init(initialState: .init(), reducer: { HostApp() })
public struct TabContainer: View {
let store: StoreOf
@ObservedObject var toastController: ToastController
+ @ObservedObject private var featureFlags = FeatureFlagManager.shared
@State private var tabBarItems = [TabBarItem]()
- @State var tag: Int = 0
+ @Binding var tag: TabIndex
public init() {
toastController = ToastControllerDependencyKey.liveValue
store = hostAppStore
+ _tag = Binding(
+ get: { hostAppStore.state.activeTabIndex },
+ set: { hostAppStore.send(.setActiveTab($0)) }
+ )
}
init(store: StoreOf, toastController: ToastController) {
self.store = store
self.toastController = toastController
+ _tag = Binding(
+ get: { store.state.activeTabIndex },
+ set: { store.send(.setActiveTab($0)) }
+ )
}
public var body: some View {
@@ -31,26 +43,21 @@ public struct TabContainer: View {
TabBar(tag: $tag, tabBarItems: tabBarItems)
.padding(.bottom, 8)
ZStack(alignment: .center) {
- GeneralView(store: store.scope(state: \.general, action: \.general))
- .tabBarItem(
- tag: 0,
- title: "General",
- image: "CopilotLogo",
- isSystemImage: false
- )
- AdvancedSettings().tabBarItem(
- tag: 2,
- title: "Advanced",
- image: "gearshape.2.fill"
- )
+ GeneralView(store: store.scope(state: \.general, action: \.general)).tabBarItem(for: .general)
+ AdvancedSettings().tabBarItem(for: .advanced)
+ if featureFlags.isAgentModeEnabled {
+ MCPConfigView().tabBarItem(for: .tools)
+ }
+ if featureFlags.isBYOKEnabled {
+ BYOKConfigView().tabBarItem(for: .byok)
+ }
}
.environment(\.tabBarTabTag, tag)
.frame(minHeight: 400)
}
.focusable(false)
.padding(.top, 8)
- .background(.ultraThinMaterial.opacity(0.01))
- .background(Color(nsColor: .controlBackgroundColor).opacity(0.4))
+ .background(Color(nsColor: .controlBackgroundColor))
.handleToast()
.onPreferenceChange(TabBarItemPreferenceKey.self) { items in
tabBarItems = items
@@ -58,12 +65,22 @@ public struct TabContainer: View {
.onAppear {
store.send(.appear)
}
+ .onChange(of: featureFlags.isAgentModeEnabled) { isEnabled in
+ if hostAppStore.state.activeTabIndex == .tools && !isEnabled {
+ hostAppStore.send(.setActiveTab(.general))
+ }
+ }
+ .onChange(of: featureFlags.isBYOKEnabled) { isEnabled in
+ if hostAppStore.state.activeTabIndex == .byok && !isEnabled {
+ hostAppStore.send(.setActiveTab(.general))
+ }
+ }
}
}
}
struct TabBar: View {
- @Binding var tag: Int
+ @Binding var tag: TabIndex
fileprivate var tabBarItems: [TabBarItem]
var body: some View {
@@ -82,9 +99,9 @@ struct TabBar: View {
}
struct TabBarButton: View {
- @Binding var currentTag: Int
+ @Binding var currentTag: TabIndex
@State var isHovered = false
- var tag: Int
+ var tag: TabIndex
var title: String
var image: String
var isSystemImage: Bool = true
@@ -115,7 +132,7 @@ struct TabBarButton: View {
.padding(.vertical, 4)
.padding(.top, 4)
.background(
- tag == currentTag
+ isSelected
? Color(nsColor: .textColor).opacity(0.1)
: Color.clear,
in: RoundedRectangle(cornerRadius: 8)
@@ -136,7 +153,7 @@ struct TabBarButton: View {
private struct TabBarTabViewWrapper: View {
@Environment(\.tabBarTabTag) var tabBarTabTag
- var tag: Int
+ var tag: TabIndex
var title: String
var image: String
var isSystemImage: Bool = true
@@ -158,25 +175,20 @@ private struct TabBarTabViewWrapper: View {
}
private extension View {
- func tabBarItem(
- tag: Int,
- title: String,
- image: String,
- isSystemImage: Bool = true
- ) -> some View {
+ func tabBarItem(for tag: TabIndex) -> some View {
TabBarTabViewWrapper(
tag: tag,
- title: title,
- image: image,
- isSystemImage: isSystemImage,
+ title: tag.title,
+ image: tag.image,
+ isSystemImage: tag.isSystemImage,
content: { self }
)
}
}
private struct TabBarItem: Identifiable, Equatable {
- var id: Int { tag }
- var tag: Int
+ var id: TabIndex { tag }
+ var tag: TabIndex
var title: String
var image: String
var isSystemImage: Bool = true
@@ -190,11 +202,11 @@ private struct TabBarItemPreferenceKey: PreferenceKey {
}
private struct TabBarTabTagKey: EnvironmentKey {
- static var defaultValue: Int = 0
+ static var defaultValue: TabIndex = .general
}
private extension EnvironmentValues {
- var tabBarTabTag: Int {
+ var tabBarTabTag: TabIndex {
get { self[TabBarTabTagKey.self] }
set { self[TabBarTabTagKey.self] = newValue }
}
@@ -225,12 +237,35 @@ struct TabContainer_Toasts_Previews: PreviewProvider {
TabContainer(
store: .init(initialState: .init(), reducer: { HostApp() }),
toastController: .init(messages: [
- .init(id: UUID(), type: .info, content: Text("info")),
- .init(id: UUID(), type: .error, content: Text("error")),
- .init(id: UUID(), type: .warning, content: Text("warning")),
+ .init(id: UUID(), level: .info, content: Text("info")),
+ .init(id: UUID(), level: .error, content: Text("error")),
+ .init(id: UUID(), level: .warning, content: Text("warning")),
])
)
.frame(width: 800)
}
}
+@available(macOS 14.0, *)
+@MainActor
+public struct SettingsEnvironment: View {
+ @Environment(\.openSettings) public var openSettings: OpenSettingsAction
+
+ public init() {}
+
+ public var body: some View {
+ EmptyView().onAppear {
+ openSettings()
+ }
+ }
+
+ public func open() {
+ let controller = NSHostingController(rootView: self)
+ let window = NSWindow(contentViewController: controller)
+ window.orderFront(nil)
+ // Close the temporary window after settings are opened
+ DispatchQueue.main.async {
+ window.close()
+ }
+ }
+}
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/ToolsSettings/CopilotMCPToolManagerObservable.swift b/Core/Sources/HostApp/ToolsSettings/CopilotMCPToolManagerObservable.swift
new file mode 100644
index 00000000..8c3444d2
--- /dev/null
+++ b/Core/Sources/HostApp/ToolsSettings/CopilotMCPToolManagerObservable.swift
@@ -0,0 +1,58 @@
+import SwiftUI
+import Combine
+import Persist
+import GitHubCopilotService
+import Client
+import Logger
+
+class CopilotMCPToolManagerObservable: ObservableObject {
+ static let shared = CopilotMCPToolManagerObservable()
+
+ @Published var availableMCPServerTools: [MCPServerToolsCollection] = []
+ private var cancellables = Set()
+
+ private init() {
+ DistributedNotificationCenter.default()
+ .publisher(for: .gitHubCopilotMCPToolsDidChange)
+ .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()
+ }
+ }
+ .store(in: &cancellables)
+
+ Task {
+ // Initial load of MCP server tools collections from ExtensionService process
+ await refreshMCPServerTools()
+ }
+ }
+
+ @MainActor
+ private func refreshMCPServerTools() async {
+ Logger.client.info("Refreshing MCP server tools...")
+ do {
+ let service = try getService()
+ let mcpTools = try await service.getAvailableMCPServerToolsCollections()
+ refreshTools(tools: mcpTools)
+ } catch {
+ Logger.client.error("Failed to fetch MCP server tools: \(error)")
+ }
+ }
+
+ 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
+ }
+
+ let totalToolsCount = tools.reduce(0) { $0 + $1.tools.count }
+ let serverNames = tools.map { $0.name }.joined(separator: ", ")
+ Logger.client.info("Refreshed MCP tools - Servers: \(tools.count), Total tools: \(totalToolsCount), Server names: [\(serverNames)]")
+
+ self.availableMCPServerTools = tools
+ }
+}
diff --git a/Core/Sources/HostApp/ToolsSettings/MCPConfigConstants.swift b/Core/Sources/HostApp/ToolsSettings/MCPConfigConstants.swift
new file mode 100644
index 00000000..07220aef
--- /dev/null
+++ b/Core/Sources/HostApp/ToolsSettings/MCPConfigConstants.swift
@@ -0,0 +1,4 @@
+import Foundation
+
+let configDirectory = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".config/github-copilot/xcode")
+let mcpConfigFilePath = configDirectory.appendingPathComponent("mcp.json").path
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/ToolsSettings/MCPToolsListContainerView.swift b/Core/Sources/HostApp/ToolsSettings/MCPToolsListContainerView.swift
new file mode 100644
index 00000000..ecf30952
--- /dev/null
+++ b/Core/Sources/HostApp/ToolsSettings/MCPToolsListContainerView.swift
@@ -0,0 +1,38 @@
+import SwiftUI
+import GitHubCopilotService
+import ConversationServiceProvider
+
+/// Main list view containing all the tools
+struct MCPToolsListContainerView: View {
+ let mcpServerTools: [MCPServerToolsCollection]
+ @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) {
+ ForEach(mcpServerTools, id: \.name) { serverTools in
+ MCPServerToolsSection(
+ serverTools: serverTools,
+ isServerEnabled: serverToggleBinding(for: serverTools.name),
+ 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 {
+ Binding(
+ get: { serverToggleStates[serverName] ?? true },
+ set: { serverToggleStates[serverName] = $0 }
+ )
+ }
+}
diff --git a/Core/Sources/HostApp/ToolsSettings/MCPToolsListView.swift b/Core/Sources/HostApp/ToolsSettings/MCPToolsListView.swift
new file mode 100644
index 00000000..ba8e1b4f
--- /dev/null
+++ b/Core/Sources/HostApp/ToolsSettings/MCPToolsListView.swift
@@ -0,0 +1,114 @@
+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:
+ 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)
+ }
+ ) {
+ let filteredServerTools = filteredMCPServerTools()
+ if filteredServerTools.isEmpty {
+ EmptyStateView()
+ } else {
+ ToolsListView(
+ mcpServerTools: filteredServerTools,
+ serverToggleStates: $serverToggleStates,
+ searchKey: searchText,
+ expandedServerNames: expandedServerNames(filteredServerTools: filteredServerTools),
+ isInteractionAllowed: isInteractionAllowed(),
+ modes: $modes,
+ selectedMode: $selectedMode
+ )
+ }
+ }
+ .groupBoxStyle(CardGroupBoxStyle())
+ }
+ .onAppear(perform: updateServerToggleStates)
+ .onChange(of: mcpToolManager.availableMCPServerTools) { _ in
+ updateServerToggleStates()
+ }
+ .onChange(of: selectedMode) { _ in
+ updateServerToggleStates()
+ }
+ }
+
+ private func updateServerToggleStates() {
+ serverToggleStates = mcpToolManager.availableMCPServerTools.reduce(into: [:]) { result, server in
+ result[server.name] = !server.tools.isEmpty && !server.tools.allSatisfy { $0._status != .enabled }
+ }
+ }
+
+ private func filteredMCPServerTools() -> [MCPServerToolsCollection] {
+ let key = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ guard !key.isEmpty else { return mcpToolManager.availableMCPServerTools }
+ return mcpToolManager.availableMCPServerTools.compactMap { server in
+ // If server name contains the search key, return the entire server with all tools
+ if server.name.lowercased().contains(key) {
+ return server
+ }
+
+ // Otherwise, filter tools by name and description
+ let filteredTools = server.tools.filter { tool in
+ tool.name.lowercased().contains(key) || (tool.description?.lowercased().contains(key) ?? false)
+ }
+ if filteredTools.isEmpty { return nil }
+ return MCPServerToolsCollection(
+ name: server.name,
+ status: server.status,
+ tools: filteredTools,
+ error: server.error
+ )
+ }
+ }
+
+ private func expandedServerNames(filteredServerTools: [MCPServerToolsCollection]) -> Set {
+ // 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
+private struct EmptyStateView: View {
+ var body: some View {
+ Text("No MCP tools available. Make sure your MCP server is configured correctly and running.")
+ .foregroundColor(.secondary)
+ }
+}
+
+// Private components now defined in separate files:
+// MCPToolsListContainerView - in MCPToolsListContainerView.swift
+// MCPServerToolsSection - in MCPServerToolsSection.swift
+
+/// Private alias for maintaining backward compatibility
+private typealias ToolsListView = MCPToolsListContainerView
+private typealias ServerToolsSection = MCPServerToolsSection
diff --git a/Core/Sources/HostApp/ToolsSettings/MCPXcodeServerInstallView.swift b/Core/Sources/HostApp/ToolsSettings/MCPXcodeServerInstallView.swift
new file mode 100644
index 00000000..ccf83061
--- /dev/null
+++ b/Core/Sources/HostApp/ToolsSettings/MCPXcodeServerInstallView.swift
@@ -0,0 +1,234 @@
+import GitHubCopilotService
+import Logger
+import SharedUIComponents
+import SwiftUI
+import SystemUtils
+
+struct MCPXcodeServerInstallView: View {
+ @State private var xcodeVersion: String? = SystemUtils.xcodeVersion
+ @State private var isConfigured: Bool = false
+ @State private var isInstalling: Bool = false
+ @State private var installError: String? = nil
+ /// Server names from mcp.json whose config matches xcrun mcpbridge.
+ /// Cached to avoid repeated file I/O during SwiftUI rendering.
+ @State private var configuredXcodeServerNames: Set = []
+ @ObservedObject private var mcpToolManager = CopilotMCPToolManagerObservable.shared
+ @ObservedObject private var registryService = MCPRegistryService.shared
+
+ private let requiredXcodeVersion = "26.4"
+ private let serverName = "xcode"
+
+ private var meetsVersionRequirement: Bool {
+ guard let version = xcodeVersion else { return false }
+ return version.compare(requiredXcodeVersion, options: .numeric) != .orderedAscending
+ }
+
+ private var isConnected: Bool {
+ mcpToolManager.availableMCPServerTools.contains { server in
+ configuredXcodeServerNames.contains(server.name) &&
+ server.status == .running &&
+ !server.tools.isEmpty
+ }
+ }
+
+ /// Configured in mcp.json but not yet showing in available tools from the language server
+ private var isConfiguredButNotConnected: Bool {
+ isConfigured && !isConnected
+ }
+
+ private var isAlreadyInstalled: Bool {
+ isConfigured || isConnected
+ }
+
+ private var isRegistryOnly: Bool {
+ registryService.mcpRegistryEntries?.first?.registryAccess == .registryOnly
+ }
+
+ var body: some View {
+ HStack(alignment: .center, spacing: 16) {
+ VStack(alignment: .leading, spacing: 0) {
+ Text("Xcode MCP Server")
+ .font(.headline)
+ .padding(.vertical, 4)
+
+ subtitleView()
+ .font(.subheadline)
+ .foregroundColor(.secondary)
+ }
+
+ Spacer()
+
+ actionsView()
+ .padding(.vertical, 12)
+ }
+ .padding(EdgeInsets(top: 8, leading: 20, bottom: 8, trailing: 20))
+ .background(QuaternarySystemFillColor.opacity(0.75))
+ .settingsContainerStyle(isExpanded: false)
+ .onAppear {
+ checkInstallationStatus()
+ Task { await registryService.refreshAllowlist() }
+ }
+ .onChange(of: mcpToolManager.availableMCPServerTools) { _ in
+ checkInstallationStatus()
+ }
+ }
+
+ // MARK: - Subviews
+
+ @ViewBuilder
+ private func subtitleView() -> some View {
+ if !meetsVersionRequirement {
+ let versionText = xcodeVersion ?? "unknown"
+ Text("Requires Xcode \(requiredXcodeVersion) or later. Current version: \(versionText).")
+ } else if isConnected {
+ Text("Xcode's built-in MCP server is connected, enabling richer editor integration.")
+ } else if isRegistryOnly {
+ Text("Manual installation of Xcode's built-in MCP server is blocked by your organization's registry policy. Please check the MCP Registry for an approved installation option, or contact your enterprise IT administrator.")
+ } else if isConfiguredButNotConnected {
+ Text("Please confirm in Xcode to allow the built-in MCP server.")
+ } else {
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Connect Copilot to Xcode's built-in MCP server to enable richer editor integration.")
+ if let installError {
+ Text(installError)
+ .font(.caption)
+ .foregroundColor(.red)
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func actionsView() -> some View {
+ if !meetsVersionRequirement {
+ EmptyView()
+ } else if isConnected {
+ Text("Connected").foregroundColor(.secondary)
+ } else if isRegistryOnly {
+ EmptyView()
+ } else if isConfiguredButNotConnected {
+ HStack(spacing: 6) {
+ ProgressView()
+ .controlSize(.small)
+ Text("Waiting for connection...")
+ .foregroundColor(.secondary)
+ }
+ } else {
+ Button {
+ installXcodeMCPServer()
+ } label: {
+ HStack(spacing: 4) {
+ if isInstalling {
+ ProgressView()
+ .controlSize(.small)
+ } else {
+ Image(systemName: "plus.circle")
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 12, height: 12, alignment: .center)
+ .padding(2)
+ }
+ Text("Install")
+ }
+ .conditionalFontWeight(.semibold)
+ }
+ .buttonStyle(.bordered)
+ .disabled(isInstalling)
+ }
+ }
+
+ // MARK: - Actions
+
+ private func checkInstallationStatus() {
+ let (configured, names) = readXcodeMCPServerNamesFromConfig()
+ isConfigured = configured
+ configuredXcodeServerNames = names
+ }
+
+ /// Returns (isConfigured, setOfMatchingServerNames) by reading mcp.json once.
+ private func readXcodeMCPServerNamesFromConfig() -> (Bool, Set) {
+ let configFileURL = URL(fileURLWithPath: mcpConfigFilePath)
+ guard FileManager.default.fileExists(atPath: configFileURL.path),
+ let data = try? Data(contentsOf: configFileURL),
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let servers = json["servers"] as? [String: Any]
+ else {
+ return (false, [])
+ }
+
+ var names = Set()
+ for (key, value) in servers {
+ guard let serverConfig = value as? [String: Any] else { continue }
+ let command = serverConfig["command"] as? String ?? ""
+ let args = serverConfig["args"] as? [String] ?? []
+ if command.contains("xcrun") && args.contains(where: { $0.contains("mcpbridge") }) {
+ names.insert(key)
+ }
+ }
+ return (!names.isEmpty, names)
+ }
+
+ private func installXcodeMCPServer() {
+ isInstalling = true
+ installError = nil
+
+ let configFileURL = URL(fileURLWithPath: mcpConfigFilePath)
+ let fileManager = FileManager.default
+
+ do {
+ if !fileManager.fileExists(atPath: configDirectory.path) {
+ try fileManager.createDirectory(
+ at: configDirectory,
+ withIntermediateDirectories: true
+ )
+ }
+
+ var config: [String: Any]
+ if fileManager.fileExists(atPath: configFileURL.path),
+ let data = try? Data(contentsOf: configFileURL),
+ let existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
+ {
+ config = existing
+ } else {
+ config = ["servers": [String: Any]()]
+ }
+
+ var servers = config["servers"] as? [String: Any] ?? [:]
+
+ // Skip write if the entry already points to xcrun mcpbridge
+ if let existing = servers[serverName] as? [String: Any],
+ let command = existing["command"] as? String,
+ let args = existing["args"] as? [String],
+ command.contains("xcrun") && args.contains(where: { $0.contains("mcpbridge") })
+ {
+ isConfigured = true
+ configuredXcodeServerNames.insert(serverName)
+ isInstalling = false
+ return
+ }
+
+ servers[serverName] = [
+ "type": "stdio",
+ "command": "xcrun",
+ "args": ["mcpbridge"]
+ ]
+
+ config["servers"] = servers
+
+ let jsonData = try JSONSerialization.data(
+ withJSONObject: config,
+ options: [.prettyPrinted, .sortedKeys]
+ )
+ try jsonData.write(to: configFileURL, options: .atomic)
+
+ isConfigured = true
+ configuredXcodeServerNames.insert(serverName)
+ Logger.client.info("Successfully added Xcode MCP Server to configuration")
+ } catch {
+ installError = "Failed to update configuration: \(error.localizedDescription)"
+ Logger.client.error("Failed to install Xcode MCP Server: \(error)")
+ }
+
+ isInstalling = false
+ }
+}
diff --git a/Core/Sources/HostApp/ToolsSettings/ToolRowView.swift b/Core/Sources/HostApp/ToolsSettings/ToolRowView.swift
new file mode 100644
index 00000000..d8df5965
--- /dev/null
+++ b/Core/Sources/HostApp/ToolsSettings/ToolRowView.swift
@@ -0,0 +1,43 @@
+import SwiftUI
+import ConversationServiceProvider
+
+/// Individual tool row
+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: { newValue in
+ isToolEnabled = newValue
+ onToolToggleChanged(newValue)
+ }
+ )) {
+ VStack(alignment: .leading, spacing: 0) {
+ HStack(alignment: .center, spacing: 8) {
+ Text(toolName).fontWeight(.medium)
+
+ if let description = toolDescription {
+ Text(description)
+ .font(.system(size: 11))
+ .foregroundColor(.secondary)
+ .lineLimit(1)
+ .help(description)
+ }
+ }
+
+ Divider().padding(.vertical, 4)
+ }
+ }
+ .disabled(!isInteractionAllowed)
+ }
+ .padding(.vertical, 0)
+ }
+}
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 2dfa2695..1cee8bf2 100644
--- a/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift
+++ b/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift
@@ -24,98 +24,53 @@ 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()
+ }
+
+ public func isBackgroundPermissionGranted() async -> Bool {
+ // On macOS 13+, check SMAppService status
+ let bridgeLaunchAgent = SMAppService.agent(plistName: "bridgeLaunchAgent.plist")
+ let status = bridgeLaunchAgent.status
+ return status != .requiresApproval
}
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
new file mode 100644
index 00000000..8d73d1d3
--- /dev/null
+++ b/Core/Sources/PersistMiddleware/Extensions/ChatMessage+Storage.swift
@@ -0,0 +1,184 @@
+import Foundation
+import ChatAPIService
+import Persist
+import Logger
+import ConversationServiceProvider
+
+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,
+ 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,
+ 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
+ let encoder = JSONEncoder()
+ let encodeData = (try? encoder.encode(turnItemData)) ?? Data()
+ let data = String(data: encodeData, encoding: .utf8) ?? "{}"
+
+ return TurnItem(id: self.id, conversationID: self.chatTabID, CLSTurnID: self.clsTurnID, role: role.rawValue, data: data, createdAt: self.createdAt, updatedAt: self.updatedAt)
+ }
+
+ static func from(_ turnItem: TurnItem) -> ChatMessage? {
+ var chatMessage: ChatMessage? = nil
+
+ do {
+ if let jsonData = turnItem.data.data(using: .utf8) {
+ let decoder = JSONDecoder()
+ let turnItemData = try decoder.decode(TurnItemData.self, from: jsonData)
+
+ chatMessage = .init(
+ id: turnItem.id,
+ chatTabID: turnItem.conversationID,
+ clsTurnID: turnItem.CLSTurnID,
+ role: ChatMessage.Role(rawValue: turnItem.role)!,
+ content: turnItemData.content,
+ contentImageReferences: turnItemData.contentImageReferences,
+ references: turnItemData.references,
+ followUp: turnItemData.followUp,
+ suggestedTitle: turnItemData.suggestedTitle,
+ errorMessages: turnItemData.errorMessages,
+ 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
+ )
+ }
+ } catch {
+ Logger.client.error("Failed to restore chat message: \(error)")
+ }
+
+ return chatMessage
+ }
+}
+
+extension Array where Element == ChatMessage {
+ func toTurnItems() -> [TurnItem] {
+ return self.map { $0.toTurnItem() }
+ }
+}
diff --git a/Core/Sources/PersistMiddleware/Extensions/ChatTabInfo+Storage.swift b/Core/Sources/PersistMiddleware/Extensions/ChatTabInfo+Storage.swift
new file mode 100644
index 00000000..f642cb71
--- /dev/null
+++ b/Core/Sources/PersistMiddleware/Extensions/ChatTabInfo+Storage.swift
@@ -0,0 +1,48 @@
+import Foundation
+import ChatTab
+import Persist
+import Logger
+
+extension ChatTabInfo {
+
+ func toConversationItem() -> ConversationItem {
+ // Currently, no additional data to store.
+ let data = "{}"
+
+ return ConversationItem(id: self.id, title: self.title, isSelected: self.isSelected, CLSConversationID: self.CLSConversationID, data: data, createdAt: self.createdAt, updatedAt: self.updatedAt)
+ }
+
+ static func from(_ conversationItem: ConversationItem, with metadata: StorageMetadata) -> ChatTabInfo? {
+ var chatTabInfo: ChatTabInfo? = nil
+
+ chatTabInfo = .init(
+ id: conversationItem.id,
+ title: conversationItem.title,
+ isSelected: conversationItem.isSelected,
+ CLSConversationID: conversationItem.CLSConversationID,
+ createdAt: conversationItem.createdAt,
+ updatedAt: conversationItem.updatedAt,
+ workspacePath: metadata.workspacePath,
+ username: metadata.username)
+
+ return chatTabInfo
+ }
+}
+
+
+extension Array where Element == ChatTabInfo {
+ func toConversationItems() -> [ConversationItem] {
+ return self.map { $0.toConversationItem() }
+ }
+}
+
+extension ChatTabPreviewInfo {
+ static func from(_ conversationPreviewItem: ConversationPreviewItem) -> ChatTabPreviewInfo {
+ return .init(
+ id: conversationPreviewItem.id,
+ title: conversationPreviewItem.title,
+ isSelected: conversationPreviewItem.isSelected,
+ updatedAt: conversationPreviewItem.updatedAt
+ )
+ }
+}
diff --git a/Core/Sources/PersistMiddleware/Stores/ChatMessageStore.swift b/Core/Sources/PersistMiddleware/Stores/ChatMessageStore.swift
new file mode 100644
index 00000000..f3061006
--- /dev/null
+++ b/Core/Sources/PersistMiddleware/Stores/ChatMessageStore.swift
@@ -0,0 +1,32 @@
+import Persist
+import ChatAPIService
+
+public struct ChatMessageStore {
+ public static func save(_ chatMessage: ChatMessage, with metadata: StorageMetadata) {
+ let turnItem = chatMessage.toTurnItem()
+ ConversationStorageService.shared.operate(
+ OperationRequest([.upsertTurn([turnItem])]),
+ metadata: metadata)
+ }
+
+ public static func delete(by id: String, with metadata: StorageMetadata) {
+ ConversationStorageService.shared.operate(
+ OperationRequest([.delete([.turn(id: id)])]), metadata: metadata)
+ }
+
+ public static func deleteAll(by ids: [String], with metadata: StorageMetadata) {
+ ConversationStorageService.shared.operate(
+ OperationRequest([.delete(ids.map { .turn(id: $0)})]), metadata: metadata)
+ }
+
+ public static func getAll(by conversationID: String, metadata: StorageMetadata) -> [ChatMessage] {
+ var chatMessages: [ChatMessage] = []
+
+ let turnItems = ConversationStorageService.shared.fetchTurnItems(for: conversationID, metadata: metadata)
+ if turnItems.count > 0 {
+ chatMessages = turnItems.compactMap { ChatMessage.from($0) }
+ }
+
+ return chatMessages
+ }
+}
diff --git a/Core/Sources/PersistMiddleware/Stores/ChatTabInfoStore.swift b/Core/Sources/PersistMiddleware/Stores/ChatTabInfoStore.swift
new file mode 100644
index 00000000..da9bccd3
--- /dev/null
+++ b/Core/Sources/PersistMiddleware/Stores/ChatTabInfoStore.swift
@@ -0,0 +1,52 @@
+import Persist
+import ChatTab
+
+public struct ChatTabInfoStore {
+ public static func saveAll(_ chatTabInfos: [ChatTabInfo], with metadata: StorageMetadata) {
+ let conversationItems = chatTabInfos.toConversationItems()
+ ConversationStorageService.shared.operate(
+ OperationRequest([.upsertConversation(conversationItems)]), metadata: metadata)
+ }
+
+ public static func delete(by id: String, with metadata: StorageMetadata) {
+ ConversationStorageService.shared.operate(
+ OperationRequest(
+ [.delete([.conversation(id: id), .turnByConversationID(conversationID: id)])]),
+ metadata: metadata)
+ }
+
+ public static func getAll(with metadata: StorageMetadata) -> [ChatTabInfo] {
+ return fetchChatTabInfos(.all, metadata: metadata)
+ }
+
+ public static func getSelected(with metadata: StorageMetadata) -> ChatTabInfo? {
+ return fetchChatTabInfos(.selected, metadata: metadata).first
+ }
+
+ public static func getLatest(with metadata: StorageMetadata) -> ChatTabInfo? {
+ return fetchChatTabInfos(.latest, metadata: metadata).first
+ }
+
+ public static func getByID(_ id: String, with metadata: StorageMetadata) -> ChatTabInfo? {
+ return fetchChatTabInfos(.id(id), metadata: metadata).first
+ }
+
+ private static func fetchChatTabInfos(_ type: ConversationFetchType, metadata: StorageMetadata) -> [ChatTabInfo] {
+ let items = ConversationStorageService.shared.fetchConversationItems(type, metadata: metadata)
+
+ return items.compactMap { ChatTabInfo.from($0, with: metadata) }
+ }
+}
+
+public struct ChatTabPreviewInfoStore {
+ public static func getAll(with metadata: StorageMetadata) -> [ChatTabPreviewInfo] {
+ var previewInfos: [ChatTabPreviewInfo] = []
+
+ let conversationPreviewItems = ConversationStorageService.shared.fetchConversationPreviewItems(metadata: metadata)
+ if conversationPreviewItems.count > 0 {
+ previewInfos = conversationPreviewItems.compactMap { ChatTabPreviewInfo.from($0) }
+ }
+
+ return previewInfos
+ }
+}
diff --git a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift
index 7d7ac08a..6b8d0094 100644
--- a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift
+++ b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift
@@ -8,6 +8,10 @@ import Dependencies
import Preferences
import SuggestionBasic
import SuggestionWidget
+import PersistMiddleware
+import ChatService
+import Persist
+import Workspace
#if canImport(ChatTabPersistent)
import ChatTabPersistent
@@ -39,8 +43,8 @@ struct GUI {
case toggleWidgetsHotkeyPressed
case suggestionWidget(WidgetFeature.Action)
- case switchWorkspace(path: String, name: String)
- case initWorkspaceChatTabIfNeeded(path: String)
+ case switchWorkspace(path: String, name: String, username: String)
+ case initWorkspaceChatTabIfNeeded(path: String, username: String)
static func promptToCodeGroup(_ action: PromptToCodeGroup.Action) -> Self {
.suggestionWidget(.panel(.sharedPanel(.promptToCodeGroup(action))))
@@ -68,7 +72,7 @@ struct GUI {
state: \.chatHistory,
action: \.suggestionWidget.chatPanel
) {
- Reduce { _, action in
+ Reduce { state, action in
switch action {
case let .createNewTapButtonClicked(kind):
// return .run { send in
@@ -76,11 +80,31 @@ struct GUI {
// await send(.createNewTab(chatTabInfo))
// }
// }
+ // The chat workspace should exist before create tab
+ guard let currentChatWorkspace = state.currentChatWorkspace else { return .none }
+
return .run { send in
- if let (_, chatTabInfo) = await chatTabPool.createTab(for: kind) {
+ if let (_, chatTabInfo) = await chatTabPool.createTab(for: kind, with: currentChatWorkspace) {
await send(.appendAndSelectTab(chatTabInfo))
}
}
+ case .restoreTabByInfo(let info):
+ guard let currentChatWorkspace = state.currentChatWorkspace else { return .none }
+
+ return .run { send in
+ if let _ = await chatTabPool.restoreTab(by: info, with: currentChatWorkspace) {
+ await send(.appendAndSelectTab(info))
+ }
+ }
+
+ case .createNewTabByID(let id):
+ guard let currentChatWorkspace = state.currentChatWorkspace else { return .none }
+
+ return .run { send in
+ if let (_, info) = await chatTabPool.createTab(id: id, with: currentChatWorkspace) {
+ await send(.appendAndSelectTab(info))
+ }
+ }
// case let .closeTabButtonClicked(id):
// return .run { _ in
@@ -88,9 +112,11 @@ struct GUI {
// }
case let .chatTab(_, .openNewTab(builder)):
+ // The chat workspace should exist before create tab
+ guard let currentChatWorkspace = state.currentChatWorkspace else { return .none }
return .run { send in
if let (_, chatTabInfo) = await chatTabPool
- .createTab(from: builder.chatTabBuilder)
+ .createTab(from: builder.chatTabBuilder, with: currentChatWorkspace)
{
await send(.appendAndSelectTab(chatTabInfo))
}
@@ -132,8 +158,10 @@ struct GUI {
}
case .createAndSwitchToChatTabIfNeeded:
+ // The chat workspace should exist before create tab
+ guard let currentChatWorkspace = state.chatHistory.currentChatWorkspace else { return .none }
- if let currentChatWorkspace = state.chatHistory.currentChatWorkspace, let selectedTabInfo = currentChatWorkspace.selectedTabInfo,
+ if let selectedTabInfo = currentChatWorkspace.selectedTabInfo,
chatTabPool.getTab(of: selectedTabInfo.id) is ConversationTab
{
// Already in Chat tab
@@ -150,25 +178,25 @@ struct GUI {
}
}
return .run { send in
- if let (_, chatTabInfo) = await chatTabPool.createTab(for: nil) {
+ if let (_, chatTabInfo) = await chatTabPool.createTab(for: nil, with: currentChatWorkspace) {
await send(
.suggestionWidget(.chatPanel(.appendAndSelectTab(chatTabInfo)))
)
}
}
- case let .switchWorkspace(path, name):
+ case let .switchWorkspace(path, name, username):
return .run { send in
await send(
- .suggestionWidget(.chatPanel(.switchWorkspace(path, name)))
+ .suggestionWidget(.chatPanel(.switchWorkspace(path, name, username)))
)
- await send(.initWorkspaceChatTabIfNeeded(path: path))
}
- case let .initWorkspaceChatTabIfNeeded(path):
- guard let chatWorkspace = state.chatHistory.workspaces[id: path], chatWorkspace.tabInfo.isEmpty
+ case let .initWorkspaceChatTabIfNeeded(path, username):
+ let identifier = WorkspaceIdentifier(path: path, username: username)
+ guard let chatWorkspace = state.chatHistory.workspaces[id: identifier], chatWorkspace.tabInfo.isEmpty
else { return .none }
return .run { send in
- if let (_, chatTabInfo) = await chatTabPool.createTab(for: nil) {
+ if let (_, chatTabInfo) = await chatTabPool.createTab(for: nil, with: chatWorkspace) {
await send(
.suggestionWidget(.chatPanel(.appendTabToWorkspace(chatTabInfo, chatWorkspace)))
)
@@ -228,8 +256,10 @@ struct GUI {
}
try? await tab.service.handleCustomCommand(command)
}
+
+ guard var currentChatWorkspace = state.chatHistory.currentChatWorkspace else { return .none }
- if let info = state.chatHistory.currentChatWorkspace?.selectedTabInfo,
+ if let info = currentChatWorkspace.selectedTabInfo,
let activeTab = chatTabPool.getTab(of: info.id) as? ConversationTab
{
return .run { send in
@@ -238,22 +268,26 @@ struct GUI {
}
}
- if var chatWorkspace = state.chatHistory.currentChatWorkspace, let info = chatWorkspace.tabInfo.first(where: {
+ let chatWorkspace = currentChatWorkspace
+ if var info = currentChatWorkspace.tabInfo.first(where: {
chatTabPool.getTab(of: $0.id) is ConversationTab
}),
let chatTab = chatTabPool.getTab(of: info.id) as? ConversationTab
{
- chatWorkspace.selectedTabId = chatTab.id
- let updatedChatWorkspace = chatWorkspace
+ let (originalTab, currentTab) = currentChatWorkspace.switchTab(to: &info)
+ let updatedChatWorkspace = currentChatWorkspace
+
return .run { send in
await send(.suggestionWidget(.chatPanel(.updateChatHistory(updatedChatWorkspace))))
await send(.openChatPanel(forceDetach: false))
await stopAndHandleCommand(chatTab)
+ await send(.suggestionWidget(.chatPanel(.saveChatTabInfo([originalTab, currentTab], chatWorkspace))))
+ await send(.suggestionWidget(.chatPanel(.syncChatTabInfo([originalTab, currentTab]))))
}
}
return .run { send in
- guard let (chatTab, chatTabInfo) = await chatTabPool.createTab(for: nil)
+ guard let (chatTab, chatTabInfo) = await chatTabPool.createTab(for: nil, with: chatWorkspace)
else {
return
}
@@ -322,12 +356,18 @@ public final class GraphicalUserInterfaceController {
let widgetController: SuggestionWidgetController
let widgetDataSource: WidgetDataSource
let chatTabPool: ChatTabPool
+
+ // Used for restoring. Handle concurrency
+ var restoredChatHistory: Set = Set()
class WeakStoreHolder {
weak var store: StoreOf?
}
init() {
+ @Dependency(\.workspacePool) var workspacePool
+ @Dependency(\.workspaceInvoker) var workspaceInvoker
+
let chatTabPool = ChatTabPool()
let suggestionDependency = SuggestionWidgetControllerDependency()
let setupDependency: (inout DependencyValues) -> Void = { dependencies in
@@ -365,13 +405,13 @@ public final class GraphicalUserInterfaceController {
dependency: suggestionDependency
)
- chatTabPool.createStore = { id in
+ chatTabPool.createStore = { info in
store.scope(
state: { state in
- state.chatHistory.currentChatWorkspace?.tabInfo[id: id] ?? .init(id: id, title: "")
+ state.chatHistory.currentChatWorkspace?.tabInfo[id: info.id] ?? info
},
action: { childAction in
- .suggestionWidget(.chatPanel(.chatTab(id: id, action: childAction)))
+ .suggestionWidget(.chatPanel(.chatTab(id: info.id, action: childAction)))
}
)
}
@@ -389,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() {
@@ -404,30 +450,78 @@ extension ChatTabPool {
@MainActor
func createTab(
id: String = UUID().uuidString,
- from builder: ChatTabBuilder
+ from builder: ChatTabBuilder? = nil,
+ with chatWorkspace: ChatWorkspace
) async -> (any ChatTab, ChatTabInfo)? {
let id = id
- let info = ChatTabInfo(id: id, title: "")
- guard let chatTap = await builder.build(store: createStore(id)) else { return nil }
- setTab(chatTap)
- return (chatTap, info)
+ let info = ChatTabInfo(id: id, workspacePath: chatWorkspace.workspacePath, username: chatWorkspace.username)
+ guard let builder else {
+ let chatTab = ConversationTab(store: createStore(info), with: info)
+ setTab(chatTab)
+ return (chatTab, info)
+ }
+
+ guard let chatTab = await builder.build(store: createStore(info)) else { return nil }
+ setTab(chatTab)
+ return (chatTab, info)
}
@MainActor
func createTab(
- for kind: ChatTabKind?
+ for kind: ChatTabKind?,
+ with chatWorkspace: ChatWorkspace
) async -> (any ChatTab, ChatTabInfo)? {
let id = UUID().uuidString
- let info = ChatTabInfo(id: id, title: "")
+ let info = ChatTabInfo(id: id, workspacePath: chatWorkspace.workspacePath, username: chatWorkspace.username)
guard let builder = kind?.builder else {
- let chatTap = ConversationTab(store: createStore(id))
- setTab(chatTap)
- return (chatTap, info)
+ let chatTab = ConversationTab(store: createStore(info), with: info)
+ setTab(chatTab)
+ return (chatTab, info)
}
- guard let chatTap = await builder.build(store: createStore(id)) else { return nil }
- setTab(chatTap)
- return (chatTap, info)
+ guard let chatTab = await builder.build(store: createStore(info)) else { return nil }
+ setTab(chatTab)
+ return (chatTab, info)
+ }
+
+ @MainActor
+ func restoreTab(
+ by info: ChatTabInfo,
+ with chaWorkspace: ChatWorkspace
+ ) async -> (any ChatTab)? {
+ let chatTab = ConversationTab.restoreConversation(by: info, store: createStore(info))
+ setTab(chatTab)
+ return chatTab
}
}
+
+extension GraphicalUserInterfaceController {
+
+ @MainActor
+ public func restore(path workspacePath: String, name workspaceName: String, username: String) async -> Void {
+ let workspaceIdentifier = WorkspaceIdentifier(path: workspacePath, username: username)
+ guard !restoredChatHistory.contains(workspaceIdentifier) else { return }
+
+ // only restore once regardless of success or fail
+ restoredChatHistory.insert(workspaceIdentifier)
+
+ let metadata = StorageMetadata(workspacePath: workspacePath, username: username)
+ let selectedChatTabInfo = ChatTabInfoStore.getSelected(with: metadata) ?? ChatTabInfoStore.getLatest(with: metadata)
+
+ if let selectedChatTabInfo {
+ let chatTab = ConversationTab.restoreConversation(by: selectedChatTabInfo, store: chatTabPool.createStore(selectedChatTabInfo))
+ chatTabPool.setTab(chatTab)
+
+ let chatWorkspace = ChatWorkspace(
+ id: .init(path: workspacePath, username: username),
+ tabInfo: [selectedChatTabInfo],
+ tabCollection: [],
+ selectedTabId: selectedChatTabInfo.id
+ ) { [weak self] in
+ self?.chatTabPool.removeTab(of: $0)
+ }
+ await self.store.send(.suggestionWidget(.chatPanel(.restoreWorkspace(chatWorkspace)))).finish()
+ }
+ }
+}
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 0dfede82..99dc2a65 100644
--- a/Core/Sources/Service/Helpers.swift
+++ b/Core/Sources/Service/Helpers.swift
@@ -1,10 +1,12 @@
import Foundation
+import GitHubCopilotService
import LanguageServerProtocol
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)."
@@ -28,16 +30,38 @@ 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:
message = "Timeout."
+ 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 e285ba59..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()
}
}
@@ -125,12 +111,18 @@ public actor RealtimeSuggestionController {
do {
try await XcodeInspector.shared.safe.latestActiveXcode?
.triggerCopilotCommand(name: "Sync Text Settings")
- await Status.shared.updateExtensionStatus(.succeeded)
+ await Status.shared.updateExtensionStatus(.granted)
} catch {
if filespace.codeMetadata.uti?.isEmpty ?? true {
filespace.codeMetadata.uti = nil
}
- await Status.shared.updateExtensionStatus(.failed)
+ if let cantRunError = error as? AppInstanceInspector.CantRunCommand {
+ if cantRunError.errorDescription.contains("No bundle found") {
+ await Status.shared.updateExtensionStatus(.notGranted)
+ } else if cantRunError.errorDescription.contains("found but disabled") {
+ await Status.shared.updateExtensionStatus(.disabled)
+ }
+ }
}
}
}
@@ -144,9 +136,10 @@ public actor RealtimeSuggestionController {
))
if Task.isCancelled { return }
-
- guard UserDefaults.shared.value(for: \.realtimeSuggestionToggle)
- else { return }
+
+ // check if user loggin
+ let authStatus = await Status.shared.getAuthStatus()
+ guard authStatus.status == .loggedIn else { return }
if UserDefaults.shared.value(for: \.disableSuggestionFeatureGlobally),
let fileURL = await XcodeInspector.shared.safe.activeDocumentURL,
diff --git a/Core/Sources/Service/Service.swift b/Core/Sources/Service/Service.swift
index 463f7e91..ab6c35e2 100644
--- a/Core/Sources/Service/Service.swift
+++ b/Core/Sources/Service/Service.swift
@@ -13,6 +13,10 @@ import XcodeInspector
import XcodeThemeController
import XPCShared
import SuggestionWidget
+import Status
+import ChatService
+import Persist
+import PersistMiddleware
@globalActor public enum ServiceActor {
public actor TheActor {}
@@ -58,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 {
@@ -72,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()
@@ -90,29 +106,58 @@ public final class Service {
keyBindingManager.start()
Task {
- await XcodeInspector.shared.safe.$activeDocumentURL
- .removeDuplicates()
- .filter { $0 != .init(fileURLWithPath: "/") }
- .compactMap { $0 }
- .sink { [weak self] fileURL in
- Task {
- do {
- try await self?.workspacePool
- .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL)
- } catch {
- Logger.workspacePool.error(error)
- }
+ await Publishers.CombineLatest(
+ XcodeInspector.shared.safe.$activeDocumentURL
+ .removeDuplicates(),
+ XcodeInspector.shared.safe.$latestActiveXcode
+ )
+ .receive(on: DispatchQueue.main)
+ .sink { [weak self] documentURL, latestXcode in
+ Task {
+ let fileURL = documentURL ?? latestXcode?.realtimeDocumentURL
+ guard fileURL != nil, fileURL != .init(fileURLWithPath: "/") else {
+ return
}
- }.store(in: &cancellable)
+ do {
+ let _ = try await self?.workspacePool
+ .fetchOrCreateWorkspaceAndFilespace(
+ fileURL: fileURL!
+ )
+ } catch let error as Workspace.WorkspaceFileError {
+ Logger.workspacePool
+ .info(error.localizedDescription)
+ }
+ catch {
+ Logger.workspacePool.error(error)
+ }
+ }
+ }.store(in: &cancellable)
- await XcodeInspector.shared.safe.$activeWorkspaceURL.receive(on: DispatchQueue.main)
- .sink { newURL in
- if let path = newURL?.path, self.guiController.store.chatHistory.selectedWorkspacePath != path {
- let name = self.getDisplayNameOfXcodeWorkspace(url: newURL!)
- self.guiController.store.send(.switchWorkspace(path: path, name: name))
+ // Combine both workspace and auth status changes into a single stream
+ await Publishers.CombineLatest3(
+ XcodeInspector.shared.safe.$latestActiveXcode,
+ XcodeInspector.shared.safe.$activeWorkspaceURL
+ .removeDuplicates(),
+ StatusObserver.shared.$authStatus
+ .removeDuplicates()
+ )
+ .receive(on: DispatchQueue.main)
+ .sink { [weak self] newXcode, newURL, newStatus in
+ // First check for realtimeWorkspaceURL if activeWorkspaceURL is nil
+ if let realtimeURL = newXcode?.realtimeWorkspaceURL, newURL == nil {
+ self?.onNewActiveWorkspaceURLOrAuthStatus(
+ newURL: realtimeURL,
+ newStatus: newStatus
+ )
+ } else if let newURL = newURL {
+ // Then use activeWorkspaceURL if available
+ self?.onNewActiveWorkspaceURLOrAuthStatus(
+ newURL: newURL,
+ newStatus: newStatus
+ )
}
-
- }.store(in: &cancellable)
+ }
+ .store(in: &cancellable)
}
}
@@ -125,7 +170,7 @@ public final class Service {
private func getDisplayNameOfXcodeWorkspace(url: URL) -> String {
var name = url.lastPathComponent
- let suffixes = [".xcworkspace", ".xcodeproj"]
+ let suffixes = [".xcworkspace", ".xcodeproj", ".playground"]
for suffix in suffixes {
if name.hasSuffix(suffix) {
name = String(name.dropLast(suffix.count))
@@ -146,3 +191,42 @@ public extension Service {
}
}
+// internal extension
+extension Service {
+
+ func onNewActiveWorkspaceURLOrAuthStatus(newURL: URL?, newStatus: AuthStatus) {
+ Task { @MainActor in
+ // check path
+ guard let path = newURL?.path, path != "/",
+ // check auth status
+ newStatus.status == .loggedIn,
+ let username = newStatus.username, !username.isEmpty,
+ // Switch workspace only when the `workspace` or `username` is not the same as the current one
+ (
+ self.guiController.store.chatHistory.selectedWorkspacePath != path ||
+ self.guiController.store.chatHistory.currentUsername != username
+ )
+ else { return }
+
+ await self.doSwitchWorkspace(workspaceURL: newURL!, username: username)
+ }
+ }
+
+ /// - Parameters:
+ /// - workspaceURL: The active workspace URL that need switch to
+ /// - path: Path of the workspace URL
+ /// - username: Curent github username
+ @MainActor
+ func doSwitchWorkspace(workspaceURL: URL, username: String) async {
+ // get workspace display name
+ let name = self.getDisplayNameOfXcodeWorkspace(url: workspaceURL)
+ let path = workspaceURL.path
+
+ // switch workspace and username and wait for it to complete
+ await self.guiController.store.send(.switchWorkspace(path: path, name: name, username: username)).finish()
+ // restore if needed
+ await self.guiController.restore(path: path, name: name, username: username)
+ // init chat tab if no history tab (only after workspace is fully switched and restored)
+ await self.guiController.store.send(.initWorkspaceChatTabIfNeeded(path: path, username: username)).finish()
+ }
+}
diff --git a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift
index f919ae7b..2bdb8b91 100644
--- a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift
+++ b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift
@@ -10,12 +10,15 @@ import WorkspaceSuggestionService
import XcodeInspector
import XPCShared
import AXHelper
+import GitHubCopilotService
/// It's used to run some commands without really triggering the menu bar item.
///
/// For example, we can use it to generate real-time suggestions without Apple Scripts.
struct PseudoCommandHandler {
static var lastTimeCommandFailedToTriggerWithAccessibilityAPI = Date(timeIntervalSince1970: 0)
+ static var lastBundleNotFoundTime = Date(timeIntervalSince1970: 0)
+ static var lastBundleDisabledTime = Date(timeIntervalSince1970: 0)
private var toast: ToastController { ToastControllerDependencyKey.liveValue }
func presentPreviousSuggestion() async {
@@ -52,20 +55,95 @@ struct PseudoCommandHandler {
func generateRealtimeSuggestions(sourceEditor: SourceEditor?) async {
guard let filespace = await getFilespace(),
let (workspace, _) = try? await Service.shared.workspacePool
- .fetchOrCreateWorkspaceAndFilespace(fileURL: filespace.fileURL) else { return }
+ .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(
@@ -74,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)
}
}
@@ -125,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()
@@ -140,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 {
@@ -201,14 +353,14 @@ struct PseudoCommandHandler {
The app is using a fallback solution to accept suggestions. \
For better experience, please restart Xcode to re-activate the Copilot \
menu item.
- """, type: .warning)
+ """, level: .warning)
}
throw error
}
} catch {
guard let xcode = ActiveApplicationMonitor.shared.activeXcode
- ?? ActiveApplicationMonitor.shared.latestXcode else { return }
+ ?? ActiveApplicationMonitor.shared.latestXcode else { return }
let application = AXUIElementCreateApplication(xcode.processIdentifier)
guard let focusElement = application.focusedElement,
focusElement.description == "Source Editor"
@@ -246,30 +398,64 @@ 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 last = Self.lastTimeCommandFailedToTriggerWithAccessibilityAPI
+ let lastBundleNotFoundTime = Self.lastBundleNotFoundTime
+ let lastBundleDisabledTime = Self.lastBundleDisabledTime
let now = Date()
- if now.timeIntervalSince(last) > 60 * 60 {
- Self.lastTimeCommandFailedToTriggerWithAccessibilityAPI = now
- toast.toast(content: """
- Xcode is relying on a fallback solution for Copilot suggestions. \
- For optimal performance, please restart Xcode to reactivate Copilot.
- """, type: .warning)
+ if let cantRunError = error as? AppInstanceInspector.CantRunCommand {
+ if cantRunError.errorDescription.contains("No bundle found") {
+ // Extension permission not granted
+ if now.timeIntervalSince(lastBundleNotFoundTime) > 60 * 60 {
+ Self.lastBundleNotFoundTime = now
+ toast.toast(
+ title: "GitHub Copilot Extension Permission Not Granted",
+ content: """
+ Enable Extensions → Xcode Source Editor → GitHub Copilot \
+ for Xcode for faster and full-featured code completion. \
+ [View How-to Guide](https://github.com/github/CopilotForXcode/blob/main/TROUBLESHOOTING.md#extension-permission)
+ """,
+ level: .warning,
+ button: .init(
+ title: "Enable",
+ action: { NSWorkspace.openXcodeExtensionsPreferences() }
+ )
+ )
+ }
+ } else if cantRunError.errorDescription.contains("found but disabled") {
+ if now.timeIntervalSince(lastBundleDisabledTime) > 60 * 60 {
+ Self.lastBundleDisabledTime = now
+ toast.toast(
+ title: "GitHub Copilot Extension Disabled",
+ content: "Quit and restart Xcode to enable extension.",
+ level: .warning,
+ button: .init(
+ title: "Restart Xcode",
+ action: { NSWorkspace.restartXcode() }
+ )
+ )
+ }
+ }
}
throw error
}
} catch {
guard let xcode = ActiveApplicationMonitor.shared.activeXcode
- ?? ActiveApplicationMonitor.shared.latestXcode else { return }
+ ?? ActiveApplicationMonitor.shared.latestXcode else { return }
let application = AXUIElementCreateApplication(xcode.processIdentifier)
guard let focusElement = application.focusedElement,
focusElement.description == "Source Editor"
@@ -288,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: "",
@@ -298,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 {
@@ -306,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 }
@@ -339,20 +557,20 @@ extension PseudoCommandHandler {
PresentInWindowSuggestionPresenter()
.presentErrorMessage("Fail to set editor content.")
}
- )
+ )
}
func getFileContent(sourceEditor: AXUIElement?) async
- -> (
- content: String,
- lines: [String],
- selections: [CursorRange],
- cursorPosition: CursorPosition,
- cursorOffset: Int
- )?
+ -> (
+ content: String,
+ lines: [String],
+ selections: [CursorRange],
+ cursorPosition: CursorPosition,
+ cursorOffset: Int
+ )?
{
guard let xcode = ActiveApplicationMonitor.shared.activeXcode
- ?? ActiveApplicationMonitor.shared.latestXcode else { return nil }
+ ?? ActiveApplicationMonitor.shared.latestXcode else { return nil }
let application = AXUIElementCreateApplication(xcode.processIdentifier)
guard let focusElement = sourceEditor ?? application.focusedElement,
focusElement.description == "Source Editor"
@@ -373,7 +591,7 @@ extension PseudoCommandHandler {
guard
let fileURL = await getFileURL(),
let (_, filespace) = try? await Service.shared.workspacePool
- .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL)
+ .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL)
else { return nil }
return filespace
}
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 d97618eb..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
@@ -431,43 +501,45 @@ extension WindowBaseCommandHandler {
}.result
}
+ // not used feature
+ // commit it to avoid init error for ChatService
func executeSingleRoundDialog(
systemPrompt: String?,
overwriteSystemPrompt: Bool,
prompt: String,
receiveReplyInNotification: Bool
) async throws {
- guard !prompt.isEmpty else { return }
- let service = ChatService.service()
-
- let result = try await service.handleSingleRoundDialogCommand(
- systemPrompt: systemPrompt,
- overwriteSystemPrompt: overwriteSystemPrompt,
- prompt: prompt
- )
-
- guard receiveReplyInNotification else { return }
-
- let granted = try await UNUserNotificationCenter.current()
- .requestAuthorization(options: [.alert])
-
- if granted {
- let content = UNMutableNotificationContent()
- content.title = "Reply"
- content.body = result
- let request = UNNotificationRequest(
- identifier: "reply",
- content: content,
- trigger: nil
- )
- do {
- try await UNUserNotificationCenter.current().add(request)
- } catch {
- presenter.presentError(error)
- }
- } else {
- presenter.presentErrorMessage("Notification permission is not granted.")
- }
+// guard !prompt.isEmpty else { return }
+// let service = ChatService.service()
+//
+// let result = try await service.handleSingleRoundDialogCommand(
+// systemPrompt: systemPrompt,
+// overwriteSystemPrompt: overwriteSystemPrompt,
+// prompt: prompt
+// )
+//
+// guard receiveReplyInNotification else { return }
+//
+// let granted = try await UNUserNotificationCenter.current()
+// .requestAuthorization(options: [.alert])
+//
+// if granted {
+// let content = UNMutableNotificationContent()
+// content.title = "Reply"
+// content.body = result
+// let request = UNNotificationRequest(
+// identifier: "reply",
+// content: content,
+// trigger: nil
+// )
+// do {
+// try await UNUserNotificationCenter.current().add(request)
+// } catch {
+// presenter.presentError(error)
+// }
+// } else {
+// presenter.presentErrorMessage("Notification permission is not granted.")
+// }
}
}
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 49cab1f1..b64e841c 100644
--- a/Core/Sources/Service/XPCService.swift
+++ b/Core/Sources/Service/XPCService.swift
@@ -6,6 +6,11 @@ import Logger
import Preferences
import Status
import XPCShared
+import HostAppActivator
+import XcodeInspector
+import GitHubCopilotViewModel
+import Workspace
+import ConversationServiceProvider
public class XPCService: NSObject, XPCServiceProtocol {
// MARK: - Service
@@ -16,12 +21,33 @@ public class XPCService: NSObject, XPCServiceProtocol {
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "N/A"
)
}
+
+ public func getXPCCLSVersion(withReply reply: @escaping (String?) -> Void) {
+ Task { @MainActor in
+ do {
+ let service = try GitHubCopilotViewModel.shared.getGitHubCopilotAuthService()
+ let version = try await service.version()
+ reply(version)
+ } catch {
+ Logger.service.error("Failed to get CLS version: \(error.localizedDescription)")
+ reply(nil)
+ }
+ }
+ }
public func getXPCServiceAccessibilityPermission(withReply reply: @escaping (ObservedAXStatus) -> Void) {
Task {
reply(await Status.shared.getAXStatus())
}
}
+
+ public func getXPCServiceExtensionPermission(
+ withReply reply: @escaping (ExtensionPermissionStatus) -> Void
+ ) {
+ Task {
+ reply(await Status.shared.getExtensionStatus())
+ }
+ }
// MARK: - Suggestion
@@ -95,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,
@@ -104,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,
@@ -144,12 +188,23 @@ public class XPCService: NSObject, XPCServiceProtocol {
}
public func openChat(
- editorContent: Data,
- withReply reply: @escaping (Data?, Error?) -> Void
+ withReply reply: @escaping (Error?) -> Void
) {
- let handler = PseudoCommandHandler()
- handler.openChat(forceDetach: true)
- reply(nil, nil)
+ Task {
+ do {
+ // Check if app is already running
+ if let _ = getRunningHostApp() {
+ // App is already running, use the chat service
+ let handler = PseudoCommandHandler()
+ handler.openChat(forceDetach: true)
+ } else {
+ try launchHostAppDefault()
+ }
+ reply(nil)
+ } catch {
+ reply(error)
+ }
+ }
}
public func promptToCode(
@@ -193,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()
@@ -219,6 +297,472 @@ public class XPCService: NSObject, XPCServiceProtocol {
reply: reply
)
}
+
+ // MARK: - XcodeInspector
+
+ public func getXcodeInspectorData(withReply reply: @escaping (Data?, Error?) -> Void) {
+ do {
+ // Capture current XcodeInspector data
+ let inspectorData = XcodeInspectorData(
+ activeWorkspaceURL: XcodeInspector.shared.activeWorkspaceURL?.absoluteString,
+ activeProjectRootURL: XcodeInspector.shared.activeProjectRootURL?.absoluteString,
+ realtimeActiveWorkspaceURL: XcodeInspector.shared.realtimeActiveWorkspaceURL?.absoluteString,
+ realtimeActiveProjectURL: XcodeInspector.shared.realtimeActiveProjectURL?.absoluteString,
+ latestNonRootWorkspaceURL: XcodeInspector.shared.latestNonRootWorkspaceURL?.absoluteString
+ )
+
+ // Encode and send the data
+ let data = try JSONEncoder().encode(inspectorData)
+ reply(data, nil)
+ } catch {
+ Logger.service.error("Failed to encode XcodeInspector data: \(error.localizedDescription)")
+ reply(nil, error)
+ }
+ }
+
+ // MARK: - MCP Server Tools
+ public func getAvailableMCPServerToolsCollections(withReply reply: @escaping (Data?) -> Void) {
+ let availableMCPServerTools = CopilotMCPToolManager.getAvailableMCPServerToolsCollections()
+ if let availableMCPServerTools = availableMCPServerTools {
+ // Encode and send the data
+ let data = try? JSONEncoder().encode(availableMCPServerTools)
+ reply(data)
+ } else {
+ reply(nil)
+ }
+ }
+
+ 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 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
+ 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)
+ }
+ }
+ }
+
+ // MARK: - FeatureFlags
+ public func getCopilotFeatureFlags(
+ withReply reply: @escaping (Data?) -> Void
+ ) {
+ let featureFlags = FeatureFlagNotifierImpl.shared.featureFlags
+ let data = try? JSONEncoder().encode(featureFlags)
+ 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
+ do {
+ try await GitHubCopilotService.signOutAll()
+ } catch {
+ Logger.service.error("Failed to sign out all: \(error)")
+ }
+ }
+ }
+
+ public func getXPCServiceAuthStatus(withReply reply: @escaping (Data?) -> Void) {
+ Task { @MainActor in
+ let service = try GitHubCopilotViewModel.shared.getGitHubCopilotAuthService()
+ _ = try await service.checkStatus()
+ let authStatus = await Status.shared.getAuthStatus()
+ let data = try? JSONEncoder().encode(authStatus)
+ reply(data)
+ }
+ }
+
+ 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()
+ var saveApiKeyParams: BYOKSaveApiKeyParams? = nil
+ do {
+ saveApiKeyParams = try decoder.decode(BYOKSaveApiKeyParams.self, from: params)
+ if saveApiKeyParams == nil {
+ return
+ }
+ } catch {
+ Logger.service.error("Failed to save BYOK API Key: \(error)")
+ return
+ }
+
+ Task { @MainActor in
+ let service = try GitHubCopilotViewModel.shared.getGitHubCopilotAuthService()
+ let response = try await service.saveBYOKApiKey(saveApiKeyParams!)
+ let data = try? JSONEncoder().encode(response)
+ reply(data)
+ }
+ }
+
+ public func listBYOKApiKeys(_ params: Data, withReply reply: @escaping (Data?) -> Void) {
+ let decoder = JSONDecoder()
+ var listApiKeysParams: BYOKListApiKeysParams? = nil
+ do {
+ listApiKeysParams = try decoder.decode(BYOKListApiKeysParams.self, from: params)
+ if listApiKeysParams == nil {
+ return
+ }
+ } catch {
+ Logger.service.error("Failed to list BYOK API keys: \(error)")
+ return
+ }
+
+ Task { @MainActor in
+ let service = try GitHubCopilotViewModel.shared.getGitHubCopilotAuthService()
+ let response = try await service.listBYOKApiKeys(listApiKeysParams!)
+ if !response.apiKeys.isEmpty {
+ BYOKModelManager.updateApiKeys(apiKeys: response.apiKeys)
+ }
+ let data = try? JSONEncoder().encode(response)
+ reply(data)
+ }
+ }
+
+ public func deleteBYOKApiKey(_ params: Data, withReply reply: @escaping (Data?) -> Void) {
+ let decoder = JSONDecoder()
+ var deleteApiKeyParams: BYOKDeleteApiKeyParams? = nil
+ do {
+ deleteApiKeyParams = try decoder.decode(BYOKDeleteApiKeyParams.self, from: params)
+ if deleteApiKeyParams == nil {
+ return
+ }
+ } catch {
+ Logger.service.error("Failed to delete BYOK API Key: \(error)")
+ return
+ }
+
+ Task { @MainActor in
+ let service = try GitHubCopilotViewModel.shared.getGitHubCopilotAuthService()
+ let response = try await service.deleteBYOKApiKey(deleteApiKeyParams!)
+ let data = try? JSONEncoder().encode(response)
+ reply(data)
+ }
+ }
+
+ public func saveBYOKModel(_ params: Data, withReply reply: @escaping (Data?) -> Void) {
+ let decoder = JSONDecoder()
+ var saveModelParams: BYOKSaveModelParams? = nil
+ do {
+ saveModelParams = try decoder.decode(BYOKSaveModelParams.self, from: params)
+ if saveModelParams == nil {
+ return
+ }
+ } catch {
+ Logger.service.error("Failed to save BYOK model: \(error)")
+ return
+ }
+
+ Task { @MainActor in
+ let service = try GitHubCopilotViewModel.shared.getGitHubCopilotAuthService()
+ let response = try await service.saveBYOKModel(saveModelParams!)
+ let data = try? JSONEncoder().encode(response)
+ reply(data)
+ }
+ }
+
+ public func listBYOKModels(_ params: Data, withReply reply: @escaping (Data?, Error?) -> Void) {
+ let decoder = JSONDecoder()
+ var listModelsParams: BYOKListModelsParams? = nil
+ do {
+ listModelsParams = try decoder.decode(BYOKListModelsParams.self, from: params)
+ if listModelsParams == nil {
+ return
+ }
+ } catch {
+ Logger.service.error("Failed to list BYOK models: \(error)")
+ return
+ }
+
+ Task { @MainActor in
+ do {
+ let service = try GitHubCopilotViewModel.shared.getGitHubCopilotAuthService()
+ let response = try await service.listBYOKModels(listModelsParams!)
+ if !response.models.isEmpty && listModelsParams?.enableFetchUrl == true {
+ for model in response.models {
+ _ = try await service.saveBYOKModel(model)
+ }
+ }
+ let fullModelResponse = try await service.listBYOKModels(BYOKListModelsParams())
+ BYOKModelManager.updateBYOKModels(BYOKModels: fullModelResponse.models)
+ let data = try? JSONEncoder().encode(response)
+ reply(data, nil)
+ } catch {
+ Logger.service.error("Failed to list BYOK models: \(error)")
+ reply(nil, NSError.from(error))
+ }
+ }
+ }
+
+ public func deleteBYOKModel(_ params: Data, withReply reply: @escaping (Data?) -> Void) {
+ let decoder = JSONDecoder()
+ var deleteModelParams: BYOKDeleteModelParams? = nil
+ do {
+ deleteModelParams = try decoder.decode(BYOKDeleteModelParams.self, from: params)
+ if deleteModelParams == nil {
+ return
+ }
+ } catch {
+ Logger.service.error("Failed to delete BYOK model: \(error)")
+ return
+ }
+
+ Task { @MainActor in
+ let service = try GitHubCopilotViewModel.shared.getGitHubCopilotAuthService()
+ let response = try await service.deleteBYOKModel(deleteModelParams!)
+ let data = try? JSONEncoder().encode(response)
+ reply(data)
+ }
+ }
}
struct NoAccessToAccessibilityAPIError: Error, LocalizedError {
@@ -228,4 +772,3 @@ struct NoAccessToAccessibilityAPIError: Error, LocalizedError {
init() {}
}
-
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 6282b21c..543afb3e 100644
--- a/Core/Sources/SuggestionWidget/ChatPanelWindow.swift
+++ b/Core/Sources/SuggestionWidget/ChatPanelWindow.swift
@@ -3,12 +3,15 @@ import ChatTab
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 = {}
@@ -18,13 +21,14 @@ final class ChatPanelWindow: NSWindow {
minimizeWindow: @escaping () -> Void
) {
self.minimizeWindow = minimizeWindow
+ // Initialize with zero rect initially to prevent flashing
super.init(
contentRect: .zero,
styleMask: [.resizable, .titled, .miniaturizable, .fullSizeContentView, .closable],
backing: .buffered,
- defer: false
+ defer: true // Use defer to prevent window from appearing immediately
)
-
+
titleVisibility = .hidden
addTitlebarAccessoryViewController({
let controller = NSTitlebarAccessoryViewController()
@@ -41,11 +45,13 @@ final class ChatPanelWindow: NSWindow {
level = widgetLevel(1)
collectionBehavior = [
.fullScreenAuxiliary,
- .transient,
+// .transient,
.fullScreenPrimary,
.fullScreenAllowsTiling,
]
hasShadow = true
+
+ // Set contentView after basic configuration
contentView = NSHostingView(
rootView: ChatWindowView(
store: store,
@@ -56,8 +62,11 @@ final class ChatPanelWindow: NSWindow {
)
.environment(\.chatTabPool, chatTabPool)
)
- setIsVisible(true)
+
+ // Initialize as invisible first
+ alphaValue = 0
isPanelDisplayed = false
+ setIsVisible(true)
storeObserver.observe { [weak self] in
guard let self else { return }
@@ -70,6 +79,13 @@ final class ChatPanelWindow: NSWindow {
}
}
}
+
+ setInitialFrame()
+ }
+
+ private func setInitialFrame() {
+ let frame = UpdateLocationStrategy.getChatPanelFrame()
+ setFrame(frame, display: false, animate: true)
}
func setFloatOnTop(_ isFloatOnTop: Bool) {
@@ -107,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 29357d80..bb3747c6 100644
--- a/Core/Sources/SuggestionWidget/ChatWindow/ChatHistoryView.swift
+++ b/Core/Sources/SuggestionWidget/ChatWindow/ChatHistoryView.swift
@@ -5,6 +5,7 @@ import ComposableArchitecture
import SwiftUI
import ChatTab
import SharedUIComponents
+import PersistMiddleware
struct ChatHistoryView: View {
@@ -15,22 +16,23 @@ struct ChatHistoryView: View {
var body: some View {
WithPerceptionTracking {
- let _ = store.currentChatWorkspace?.tabInfo
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")
@@ -62,46 +66,54 @@ struct ChatHistoryView: View {
let store: StoreOf
@Binding var searchText: String
@Binding var isChatHistoryVisible: Bool
+ @State private var storedChatTabPreviewInfos: [ChatTabPreviewInfo] = []
@Environment(\.chatTabPool) var chatTabPool
var body: some View {
- ScrollView {
- LazyVStack(alignment: .leading, spacing: 0) {
- ForEach(filteredTabInfo, id: \.id) { info in
- if let tab = chatTabPool.getTab(of: info.id){
+ WithPerceptionTracking {
+ ScrollView {
+ LazyVStack(alignment: .leading, spacing: 0) {
+ ForEach(filteredTabInfo, id: \.id) { previewInfo in
ChatHistoryItemView(
store: store,
- info: info,
- content: { tab.chatConversationItem },
+ previewInfo: previewInfo,
isChatHistoryVisible: $isChatHistoryVisible
- )
- .id(info.id)
- .frame(height: 49)
- }
- else {
- EmptyView()
+ ) {
+ refreshStoredChatTabInfos()
+ }
+ .id(previewInfo.id)
+ .scaledFrame(height: 61)
}
}
}
+ .onAppear { refreshStoredChatTabInfos() }
}
}
- var filteredTabInfo: IdentifiedArray {
- guard let tabInfo = store.currentChatWorkspace?.tabInfo else {
- return []
+ func refreshStoredChatTabInfos() -> Void {
+ Task {
+ if let workspacePath = store.chatHistory.selectedWorkspacePath,
+ let username = store.chatHistory.currentUsername
+ {
+ storedChatTabPreviewInfos = ChatTabPreviewInfoStore.getAll(with: .init(workspacePath: workspacePath, username: username))
+ }
}
+ }
+
+ var filteredTabInfo: IdentifiedArray {
+ // Only compute when view is visible to prevent unnecessary computation
+ if !isChatHistoryVisible {
+ return IdentifiedArray(uniqueElements: [])
+ }
+
+ guard !searchText.isEmpty else { return IdentifiedArray(uniqueElements: storedChatTabPreviewInfos) }
- guard !searchText.isEmpty else { return tabInfo }
- let result = tabInfo.filter { info in
- if let tab = chatTabPool.getTab(of: info.id),
- let conversationTab = tab as? ConversationTab {
- return conversationTab.getChatTabTitle().localizedCaseInsensitiveContains(searchText)
- }
-
- return false
+ let result = storedChatTabPreviewInfos.filter { info in
+ return (info.title ?? "New Chat").localizedCaseInsensitiveContains(searchText)
}
- return result
+
+ return IdentifiedArray(uniqueElements: result)
}
}
}
@@ -115,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))
@@ -134,56 +148,101 @@ struct ChatHistorySearchBarView: View {
}
}
-struct ChatHistoryItemView: View {
+struct ChatHistoryItemView: View {
let store: StoreOf
- let info: ChatTabInfo
- let content: () -> Content
+ let previewInfo: ChatTabPreviewInfo
+ @Environment(\.colorScheme) var colorScheme
@Binding var isChatHistoryVisible: Bool
@State private var isHovered = false
+ let onDelete: () -> Void
+
func isTabSelected() -> Bool {
- return store.state.currentChatWorkspace?.selectedTabId == info.id
+ return store.state.currentChatWorkspace?.selectedTabId == previewInfo.id
+ }
+
+ func formatDate(_ date: Date) -> String {
+ let formatter = DateFormatter()
+ formatter.dateFormat = "MMMM d, yyyy, h:mm a"
+ return formatter.string(from: date)
}
var body: some View {
- VStack(spacing: 0) {
- HStack(alignment: .center, spacing: 0) {
- HStack(spacing: 8) {
- content()
- .font(.system(size: 14, weight: .regular))
- .lineLimit(1)
- .hoverPrimaryForeground(isHovered: isHovered)
-
- if isTabSelected() {
- Text("Current")
- .foregroundStyle(.secondary)
+ WithPerceptionTracking {
+ VStack(spacing: 0) {
+ HStack(alignment: .center, spacing: 0) {
+ VStack(spacing: 4) {
+ HStack(spacing: 8) {
+ // Do not use the `ChatConversationItemView` any more
+ // directly get title from chat tab info
+ Text(previewInfo.title ?? "New Chat")
+ .frame(alignment: .leading)
+ .scaledFont(size: 14, weight: .semibold)
+ .foregroundColor(.primary)
+ .lineLimit(1)
+
+ if isTabSelected() {
+ Text("Current")
+ .scaledFont(.footnote)
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer()
+ }
+
+ HStack(spacing: 0) {
+ Text(formatDate(previewInfo.updatedAt))
+ .frame(alignment: .leading)
+ .scaledFont(size: 13, weight: .regular)
+ .foregroundColor(.secondary)
+ .lineLimit(1)
+
+ Spacer()
+ }
}
- }
-
- Spacer()
-
- if !isTabSelected() {
- if isHovered {
+
+ Spacer()
+
+ if !isTabSelected() {
Button(action: {
- store.send(.chatHisotryDeleteButtonClicked(id: info.id))
+ Task { @MainActor in
+ await store.send(.chatHistoryDeleteButtonClicked(id: previewInfo.id)).finish()
+ onDelete()
+ }
}) {
Image(systemName: "trash")
+ .foregroundColor(.primary)
+ .scaledFont(.body)
+ .opacity(isHovered ? 1 : 0)
}
.buttonStyle(HoverButtonStyle())
.help("Delete")
+ .allowsHitTesting(isHovered)
}
}
+ .padding(.horizontal, 12)
+ }
+ .frame(maxHeight: .infinity)
+ .contentShape(Rectangle())
+ .onHover(perform: {
+ isHovered = $0
+ })
+ .hoverRadiusBackground(
+ isHovered: isHovered,
+ hoverColor: Color(
+ nsColor: .controlColor
+ .withAlphaComponent(colorScheme == .dark ? 0.1 : 0.55)
+ ),
+ cornerRadius: 8,
+ showBorder: isHovered,
+ borderColor: Color(nsColor: .separatorColor)
+ )
+ .onTapGesture {
+ Task { @MainActor in
+ await store.send(.chatHistoryItemClicked(id: previewInfo.id)).finish()
+ isChatHistoryVisible = false
+ }
}
- .padding(.horizontal, 12)
- }
- .frame(maxHeight: .infinity)
- .onHover(perform: {
- isHovered = $0
- })
- .hoverRadiusBackground(isHovered: isHovered, cornerRadius: 4)
- .onTapGesture {
- store.send(.chatHistoryItemClicked(id: info.id))
- isChatHistoryVisible = false
}
}
}
@@ -202,16 +261,16 @@ struct ChatHistoryView_Previews: PreviewProvider {
initialState: .init(
chatHistory: .init(
workspaces: [.init(
- id: "activeWorkspacePath",
+ id: .init(path: "p", username: "u"),
tabInfo: [
- .init(id: "2", title: "Empty-2"),
- .init(id: "3", title: "Empty-3"),
- .init(id: "4", title: "Empty-4"),
- .init(id: "5", title: "Empty-5"),
- .init(id: "6", title: "Empty-6")
+ .init(id: "2", title: "Empty-2", workspacePath: "path", username: "username"),
+ .init(id: "3", title: "Empty-3", workspacePath: "path", username: "username"),
+ .init(id: "4", title: "Empty-4", workspacePath: "path", username: "username"),
+ .init(id: "5", title: "Empty-5", workspacePath: "path", username: "username"),
+ .init(id: "6", title: "Empty-6", workspacePath: "path", username: "username")
] as IdentifiedArray,
selectedTabId: "2"
- )] as IdentifiedArray,
+ ) { _ in }] as IdentifiedArray,
selectedWorkspacePath: "activeWorkspacePath",
selectedWorkspaceName: "activeWorkspacePath"
),
diff --git a/Core/Sources/SuggestionWidget/ChatWindow/ChatLoginView.swift b/Core/Sources/SuggestionWidget/ChatWindow/ChatLoginView.swift
index d017d78d..0a70e8ba 100644
--- a/Core/Sources/SuggestionWidget/ChatWindow/ChatLoginView.swift
+++ b/Core/Sources/SuggestionWidget/ChatWindow/ChatLoginView.swift
@@ -10,43 +10,56 @@ struct ChatLoginView: View {
var body: some View {
WithPerceptionTracking {
VStack(spacing: 0){
- VStack(spacing: 20) {
+ VStack(spacing: 24) {
Spacer()
- Image("CopilotLogo")
- .resizable()
- .renderingMode(.template)
- .scaledToFill()
- .frame(width: 60.0, height: 60.0)
- .foregroundColor(.secondary)
-
- Text("Welcome to Copilot")
- .font(.system(size: 24))
+ VStack(spacing: 8) {
+ Image("CopilotLogo")
+ .resizable()
+ .renderingMode(.template)
+ .scaledToFill()
+ .scaledFrame(width: 60.0, height: 60.0)
+ .foregroundColor(.secondary)
+
+ Text("Welcome to Copilot")
+ .scaledFont(.largeTitle)
+ .multilineTextAlignment(.center)
+
+ Text("Your AI-powered coding assistant")
+ .scaledFont(.body)
+ .multilineTextAlignment(.center)
+ }
- Text("Your AI-powered coding assistant\nI use the power of AI to help you:")
- .font(.system(size: 12))
+ CopilotIntroView()
- Button("Sign Up for Copilot Free") {
- if let url = URL(string: "https://github.com/features/copilot/plans") {
- openURL(url)
+ VStack(spacing: 8) {
+ Button("Sign Up for Copilot Free") {
+ if let url = URL(string: "https://github.com/features/copilot/plans") {
+ openURL(url)
+ }
}
- }
- .buttonStyle(.borderedProminent)
-
- HStack{
- Text("Already have an account?")
- Button("Sign In") { viewModel.signIn() }
- .buttonStyle(.borderless)
- .foregroundColor(Color("TextLinkForegroundColor"))
+ .scaledFont(.body)
+ .buttonStyle(.borderedProminent)
- if viewModel.isRunningAction || viewModel.waitingForSignIn {
- ProgressView()
- .controlSize(.small)
+ HStack{
+ Text("Already have an account?")
+ .scaledFont(.body)
+
+ Button("Sign In") { viewModel.signIn() }
+ .scaledFont(.body)
+ .buttonStyle(.borderless)
+ .foregroundColor(Color("TextLinkForegroundColor"))
+
+ if viewModel.isRunningAction || viewModel.waitingForSignIn {
+ ProgressView()
+ .controlSize(.small)
+ }
}
}
+ .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(
@@ -54,7 +67,7 @@ struct ChatLoginView: View {
maxHeight: .infinity
)
}
- .xcodeStyleFrame(cornerRadius: 10)
+ .xcodeStyleFrame()
.ignoresSafeArea(edges: .top)
.alert(
viewModel.signInResponse?.userCode ?? "",
@@ -62,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
new file mode 100644
index 00000000..f6674e4a
--- /dev/null
+++ b/Core/Sources/SuggestionWidget/ChatWindow/ChatNoAXPermissionView.swift
@@ -0,0 +1,56 @@
+import SwiftUI
+import Perception
+import SharedUIComponents
+
+struct ChatNoAXPermissionView: View {
+ @Environment(\.openURL) private var openURL
+
+ var body: some View {
+ WithPerceptionTracking {
+ VStack(spacing: 0) {
+ VStack(alignment: .center, spacing: 20) {
+ Spacer()
+ Image("CopilotError")
+ .resizable()
+ .renderingMode(.template)
+ .scaledToFill()
+ .scaledFrame(width: 64.0, height: 64.0)
+ .foregroundColor(.primary)
+
+ Text("Accessibility Permission Required")
+ .scaledFont(.largeTitle)
+ .multilineTextAlignment(.center)
+
+ Text("Please grant accessibility permission for Github Copilot to work with Xcode.")
+ .scaledFont(.body)
+ .multilineTextAlignment(.center)
+
+ HStack{
+ Button("Open Permission Settings") {
+ if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") {
+ openURL(url)
+ }
+ }
+ .scaledFont(.body)
+ .buttonStyle(.borderedProminent)
+ }
+
+ Spacer()
+ }
+ .padding()
+ .frame(
+ maxWidth: .infinity,
+ maxHeight: .infinity
+ )
+ }
+ .xcodeStyleFrame()
+ .ignoresSafeArea(edges: .top)
+ }
+ }
+}
+
+struct ChatNoAXPermission_Previews: PreviewProvider {
+ static var previews: some View {
+ ChatNoAXPermissionView()
+ }
+}
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
new file mode 100644
index 00000000..9e342bca
--- /dev/null
+++ b/Core/Sources/SuggestionWidget/ChatWindow/ChatNoWorkspaceView.swift
@@ -0,0 +1,48 @@
+import SwiftUI
+import Perception
+import SharedUIComponents
+
+struct ChatNoWorkspaceView: View {
+ var body: some View {
+ WithPerceptionTracking {
+ VStack(spacing: 0) {
+ VStack(alignment: .center, spacing: 32) {
+ Spacer()
+ VStack (alignment: .center, spacing: 8) {
+ Image("CopilotLogo")
+ .resizable()
+ .renderingMode(.template)
+ .scaledToFill()
+ .scaledFrame(width: 64.0, height: 64.0)
+ .foregroundColor(.secondary)
+
+ Text("No Active Xcode Workspace")
+ .scaledFont(.largeTitle)
+ .multilineTextAlignment(.center)
+
+ Text("To use Copilot, open Xcode with an active workspace in focus")
+ .scaledFont(.body)
+ .multilineTextAlignment(.center)
+ }
+
+ CopilotIntroView()
+
+ Spacer()
+ }
+ .padding()
+ .frame(
+ maxWidth: .infinity,
+ maxHeight: .infinity
+ )
+ }
+ .xcodeStyleFrame()
+ .ignoresSafeArea(edges: .top)
+ }
+ }
+}
+
+struct ChatNoWorkspace_Previews: PreviewProvider {
+ static var previews: some View {
+ ChatNoWorkspaceView()
+ }
+}
diff --git a/Core/Sources/SuggestionWidget/ChatWindow/CopilotIntroView.swift b/Core/Sources/SuggestionWidget/ChatWindow/CopilotIntroView.swift
new file mode 100644
index 00000000..a7fdcec7
--- /dev/null
+++ b/Core/Sources/SuggestionWidget/ChatWindow/CopilotIntroView.swift
@@ -0,0 +1,110 @@
+import SwiftUI
+import Perception
+import SharedUIComponents
+
+struct CopilotIntroView: View {
+ var body: some View {
+ WithPerceptionTracking {
+ VStack(alignment: .center, spacing: 8) {
+ CopilotIntroItemView(
+ imageName: "CopilotLogo",
+ title: "Agent Mode",
+ description: "Activate Agent Mode to handle multi-step coding tasks with Copilot."
+ )
+
+ CopilotIntroItemView(
+ systemImage: "wrench.and.screwdriver",
+ title: "MCP Support",
+ description: "Connect to MCP to extend your Copilot with custom tools and services for advanced workflows."
+ )
+
+ CopilotIntroItemView(
+ imageName: "ChatIcon",
+ title: "Ask Mode",
+ description: "Use Ask Mode to chat with Copilot to understand, debug, or improve your code."
+ )
+
+ CopilotIntroItemView(
+ systemImage: "option",
+ title: "Code Suggestions",
+ description: "Get smart code suggestions in Xcode. Press Tab ⇥ to accept a code suggestion, or Option ⌥ to see more alternatives."
+ )
+ }
+ .padding(0)
+ .frame(maxWidth: .infinity, alignment: .center)
+ }
+ }
+}
+
+struct CopilotIntroItemView: View {
+ let image: Image
+ let title: String
+ let description: String
+
+ public init(imageName: String, title: String, description: String) {
+ self.init(
+ imageObject: Image(imageName),
+ title: title,
+ description: description
+ )
+ }
+
+ public init(systemImage: String, title: String, description: String) {
+ self.init(
+ imageObject: Image(systemName: systemImage),
+ title: title,
+ description: description
+ )
+ }
+
+ public init(imageObject: Image, title: String, description: String) {
+ self.image = imageObject
+ self.title = title
+ self.description = description
+ }
+
+ var body: some View {
+ WithPerceptionTracking {
+ VStack(alignment: .leading, spacing: 0){
+ HStack(alignment: .center, spacing: 8) {
+ image
+ .resizable()
+ .renderingMode(.template)
+ .scaledToFill()
+ .frame(width: 12, height: 12)
+ .foregroundColor(.primary)
+ .padding(.leading, 8)
+
+ Text(title)
+ .kerning(0.096)
+ .scaledFont(.body)
+ .multilineTextAlignment(.center)
+ .foregroundColor(.primary)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ Text(description)
+ .scaledFont(.body)
+ .foregroundColor(.secondary)
+ .padding(.leading, 28)
+ .padding(.top, 4)
+ .frame(maxWidth: .infinity, alignment: .topLeading)
+ }
+ .padding(8)
+ .frame(maxWidth: 360, alignment: .top)
+ .background(.primary.opacity(0.1))
+ .cornerRadius(2)
+ .overlay(
+ RoundedRectangle(cornerRadius: 2)
+ .inset(by: 0.5)
+ .stroke(lineWidth: 0)
+ )
+ }
+ }
+}
+
+struct CopilotIntroView_Previews: PreviewProvider {
+ static var previews: some View {
+ CopilotIntroView()
+ }
+}
diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift
index d12e889e..f5530b28 100644
--- a/Core/Sources/SuggestionWidget/ChatWindowView.swift
+++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift
@@ -7,6 +7,8 @@ import SwiftUI
import SharedUIComponents
import GitHubCopilotViewModel
import Status
+import ChatService
+import Workspace
private let r: Double = 8
@@ -18,17 +20,29 @@ struct ChatWindowView: View {
var body: some View {
WithPerceptionTracking {
- let _ = store.currentChatWorkspace?.selectedTabId // force re-evaluation
+ // Force re-evaluation when workspace state changes
+ let currentWorkspace = store.currentChatWorkspace
+ let _ = currentWorkspace?.selectedTabId
ZStack {
- switch statusObserver.authStatus.status {
- case .loggedIn:
- ChatView(store: store, isChatHistoryVisible: $isChatHistoryVisible)
- case .notLoggedIn:
- ChatLoginView(viewModel: GitHubCopilotViewModel.shared)
- case .notAuthorized:
- ChatNoSubscriptionView(viewModel: GitHubCopilotViewModel.shared)
- default:
- ChatLoadingView()
+ if statusObserver.observedAXStatus == .notGranted {
+ ChatNoAXPermissionView()
+ } else {
+ switch statusObserver.authStatus.status {
+ case .loggedIn:
+ if currentWorkspace == nil || (currentWorkspace?.tabInfo.isEmpty ?? true) {
+ ChatNoWorkspaceView()
+ } else if isChatHistoryVisible {
+ ChatHistoryViewWrapper(store: store, isChatHistoryVisible: $isChatHistoryVisible)
+ } else {
+ ChatView(store: store, isChatHistoryVisible: $isChatHistoryVisible)
+ }
+ case .notLoggedIn:
+ ChatLoginView(viewModel: GitHubCopilotViewModel.shared)
+ case .notAuthorized:
+ ChatNoSubscriptionView(viewModel: GitHubCopilotViewModel.shared)
+ case .unknown:
+ ChatLoginView(viewModel: GitHubCopilotViewModel.shared)
+ }
}
}
.onChange(of: store.isPanelDisplayed) { isDisplayed in
@@ -45,43 +59,50 @@ struct ChatView: View {
var body: some View {
VStack(spacing: 0) {
- Rectangle().fill(.regularMaterial).frame(height: 28)
-
- Divider()
-
- ZStack {
- VStack(spacing: 0) {
- ChatBar(store: store, isChatHistoryVisible: $isChatHistoryVisible)
- .frame(height: 32)
- .background(Color(nsColor: .windowBackgroundColor))
-
- Divider()
+ Rectangle()
+ .fill(Color.chatWindowBackgroundColor)
+ .scaledFrame(height: 28)
- 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)
-
- if isChatHistoryVisible {
- VStack(spacing: 0) {
- Rectangle().fill(.regularMaterial).frame(height: 28)
+ }
+}
- Divider()
+struct ChatHistoryViewWrapper: View {
+ let store: StoreOf
+ @Binding var isChatHistoryVisible: Bool
+
+
+ var body: some View {
+ WithPerceptionTracking {
+ VStack(spacing: 0) {
+ 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()
@@ -99,7 +120,7 @@ struct ChatLoadingView: View {
Spacer()
VStack(spacing: 24) {
- Instruction()
+ Instruction(isAgentMode: .constant(false))
ProgressView("Loading...")
@@ -111,16 +132,17 @@ struct ChatLoadingView: View {
Spacer()
}
- .xcodeStyleFrame(cornerRadius: 10)
+ .xcodeStyleFrame()
.ignoresSafeArea(edges: .top)
.frame(maxWidth: .infinity, maxHeight: .infinity)
- .background(Color(nsColor: .windowBackgroundColor))
+ .background(.ultraThinMaterial)
}
}
struct ChatTitleBar: View {
let store: StoreOf
@State var isHovering = false
+ @AppStorage(\.autoAttachChatToXcode) var autoAttachChatToXcode
var body: some View {
WithPerceptionTracking {
@@ -140,25 +162,27 @@ 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])
Spacer()
- TrafficLightButton(
- isHovering: isHovering,
- isActive: store.isDetached,
- color: Color(nsColor: .systemCyan),
- action: {
- store.send(.toggleChatPanelDetachedButtonClicked)
+ if !autoAttachChatToXcode {
+ TrafficLightButton(
+ isHovering: isHovering,
+ isActive: store.isDetached,
+ color: Color(nsColor: .systemCyan),
+ action: {
+ store.send(.toggleChatPanelDetachedButtonClicked)
+ }
+ ) {
+ Image(systemName: "pin.fill")
+ .foregroundStyle(.black.opacity(0.5))
+ .scaledFont(Font.system(size: 6).weight(.black))
+ .transformEffect(.init(translationX: 0, y: 0.5))
}
- ) {
- Image(systemName: "pin.fill")
- .foregroundStyle(.black.opacity(0.5))
- .font(Font.system(size: 6).weight(.black))
- .transformEffect(.init(translationX: 0, y: 0.5))
}
}
.buttonStyle(.plain)
@@ -188,7 +212,7 @@ struct ChatTitleBar: View {
? color
: Color(nsColor: .separatorColor)
)
- .frame(
+ .scaledFrame(
width: Style.trafficLightButtonSize,
height: Style.trafficLightButtonSize
)
@@ -208,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
@@ -227,18 +248,26 @@ struct ChatBar: View {
var body: some View {
WithPerceptionTracking {
- HStack(spacing: 0) {
- if let name = store.chatHistory.selectedWorkspaceName {
+ 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)
}
}
@@ -295,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!)
}
}
@@ -316,11 +345,12 @@ struct ChatBar: View {
Button(action: {
store.send(.createNewTapButtonClicked(kind: nil))
}) {
- Image(systemName: "plus")
+ Image(systemName: "plus.bubble")
+ .scaledFont(.body)
}
.buttonStyle(HoverButtonStyle())
- .padding(.horizontal, 4)
.help("New Chat")
+ .accessibilityLabel("New Chat")
}
}
}
@@ -334,10 +364,194 @@ struct ChatBar: View {
Button(action: {
isChatHistoryVisible = true
}) {
- Image(systemName: "clock.arrow.trianglehead.counterclockwise.rotate.90")
+ 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())
.help("Show Chats...")
+ .accessibilityLabel("Show Chats...")
+ }
+ }
+ }
+
+ struct SettingsButton: View {
+ let store: StoreOf
+
+ var body: some View {
+ WithPerceptionTracking {
+ Button(action: {
+ store.send(.openSettings)
+ }) {
+ Image(systemName: "gearshape")
+ .scaledFont(.body)
+ }
+ .buttonStyle(HoverButtonStyle())
+ .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
}
}
}
@@ -369,46 +583,92 @@ struct ChatTabBarButton: View {
struct ChatTabContainer: View {
let store: StoreOf
@Environment(\.chatTabPool) var chatTabPool
+ @State private var pasteMonitor: Any?
var body: some View {
WithPerceptionTracking {
- let tabInfo = store.currentChatWorkspace?.tabInfo
+ let tabInfoArray = store.currentChatWorkspace?.tabInfo
let selectedTabId = store.currentChatWorkspace?.selectedTabId
?? store.currentChatWorkspace?.tabInfo.first?.id
?? ""
- ZStack {
- if tabInfo == nil || tabInfo!.isEmpty {
- Text("Empty")
- } else {
- ForEach(tabInfo!) { tabInfo in
- if let tab = chatTabPool.getTab(of: tabInfo.id) {
- let isActive = tab.id == selectedTabId
- tab.body
- .opacity(isActive ? 1 : 0)
- .disabled(!isActive)
- .allowsHitTesting(isActive)
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- // move it out of window
- .rotationEffect(
- isActive ? .zero : .degrees(90),
- anchor: .topLeading
- )
- } else {
- EmptyView()
- }
- }
+ if let tabInfoArray = tabInfoArray, !tabInfoArray.isEmpty {
+ activeTabsView(
+ tabInfoArray: tabInfoArray,
+ selectedTabId: selectedTabId
+ )
+ } else {
+ // Fallback view for empty state (rarely seen in practice)
+ EmptyView().frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ }
+ .onAppear {
+ setupPasteMonitor()
+ }
+ .onDisappear {
+ removePasteMonitor()
+ }
+ }
+
+ // View displayed when there are active tabs
+ private func activeTabsView(
+ tabInfoArray: IdentifiedArray,
+ selectedTabId: String
+ ) -> some View {
+ GeometryReader { geometry in
+ if tabInfoArray[id: selectedTabId] != nil,
+ let tab = chatTabPool.getTab(of: selectedTabId) {
+ tab.body
+ .frame(
+ width: geometry.size.width,
+ height: geometry.size.height
+ )
+ } else {
+ // Fallback if selected tab is not found
+ EmptyView()
+ }
+ }
+ }
+
+ private func setupPasteMonitor() {
+ pasteMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
+ guard event.modifierFlags.contains(.command),
+ event.charactersIgnoringModifiers?.lowercased() == "v" else {
+ return event
+ }
+
+ // Find the active chat tab and forward paste event to it
+ if let activeConversationTab = getActiveConversationTab() {
+ if !activeConversationTab.handlePasteEvent() {
+ return event
}
}
+
+ return nil
+ }
+ }
+
+ private func removePasteMonitor() {
+ if let monitor = pasteMonitor {
+ NSEvent.removeMonitor(monitor)
+ pasteMonitor = nil
}
}
+
+ private func getActiveConversationTab() -> ConversationTab? {
+ guard let selectedTabId = store.currentChatWorkspace?.selectedTabId,
+ let chatTab = chatTabPool.getTab(of: selectedTabId) as? ConversationTab else {
+ return nil
+ }
+ return chatTab
+ }
}
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)
@@ -432,18 +692,18 @@ struct ChatWindowView_Previews: PreviewProvider {
chatHistory: .init(
workspaces: [
.init(
- id: "activeWorkspacePath",
+ id: .init(path: "p", username: "u"),
tabInfo: [
- .init(id: "2", title: "Empty-2"),
- .init(id: "3", title: "Empty-3"),
- .init(id: "4", title: "Empty-4"),
- .init(id: "5", title: "Empty-5"),
- .init(id: "6", title: "Empty-6"),
- .init(id: "7", title: "Empty-7"),
+ .init(id: "2", title: "Empty-2", workspacePath: "path", username: "username"),
+ .init(id: "3", title: "Empty-3", workspacePath: "path", username: "username"),
+ .init(id: "4", title: "Empty-4", workspacePath: "path", username: "username"),
+ .init(id: "5", title: "Empty-5", workspacePath: "path", username: "username"),
+ .init(id: "6", title: "Empty-6", workspacePath: "path", username: "username"),
+ .init(id: "7", title: "Empty-7", workspacePath: "path", username: "username"),
] as IdentifiedArray,
selectedTabId: "2"
- )
- ] as IdentifiedArray,
+ ) { _ in }
+ ] as IdentifiedArray,
selectedWorkspacePath: "activeWorkspacePath",
selectedWorkspaceName: "activeWorkspacePath"
),
diff --git a/Core/Sources/SuggestionWidget/CodeReviewPanelView.swift b/Core/Sources/SuggestionWidget/CodeReviewPanelView.swift
new file mode 100644
index 00000000..f7359baa
--- /dev/null
+++ b/Core/Sources/SuggestionWidget/CodeReviewPanelView.swift
@@ -0,0 +1,447 @@
+import SwiftUI
+import Combine
+import XcodeInspector
+import ComposableArchitecture
+import ConversationServiceProvider
+import LanguageServerProtocol
+import ChatService
+import SharedUIComponents
+import ConversationTab
+
+private typealias CodeReviewPanelViewStore = ViewStore
+
+private struct ViewState: Equatable {
+ let reviewComments: [ReviewComment]
+ let currentSelectedComment: ReviewComment?
+ let currentIndex: Int
+ let operatedCommentIds: Set