diff --git a/Core/Package.swift b/Core/Package.swift index faf0c12b..9e12c8fd 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -214,6 +214,7 @@ let package = Package( .target( name: "SuggestionWidget", dependencies: [ + "ChatService", "PromptToCodeService", "ConversationTab", "GitHubCopilotViewModel", diff --git a/Core/Sources/ChatService/ChatService.swift b/Core/Sources/ChatService/ChatService.swift index f88a349f..66070b0f 100644 --- a/Core/Sources/ChatService/ChatService.swift +++ b/Core/Sources/ChatService/ChatService.swift @@ -149,30 +149,7 @@ public final class ChatService: ChatServiceType, ObservableObject { private func subscribeToClientToolConfirmationEvent() { ClientToolHandlerImpl.shared.onClientToolConfirmationEvent.sink(receiveValue: { [weak self] (request, completion) in - guard let params = request.params else { return } - - // Check if this conversationId is valid (main conversation or subagent conversation) - guard let validIds = self?.conversationTurnTracking.validConversationIds, validIds.contains(params.conversationId) else { - return - } - - let parentTurnId = self?.conversationTurnTracking.turnParentMap[params.turnId] - - let editAgentRounds: [AgentRound] = [ - AgentRound(roundId: params.roundId, - reply: "", - toolCalls: [ - AgentToolCall(id: params.toolCallId, name: params.name, status: .waitForConfirmation, invokeParams: params, title: params.title) - ] - ) - ] - self?.appendToolCallHistory(turnId: params.turnId, editAgentRounds: editAgentRounds, parentTurnId: parentTurnId) - self?.pendingToolCallRequests[params.toolCallId] = ToolCallRequest( - requestId: request.id, - turnId: params.turnId, - roundId: params.roundId, - toolCallId: params.toolCallId, - completion: completion) + self?.handleClientToolConfirmationEvent(request: request, completion: completion) }).store(in: &cancellables) } @@ -298,9 +275,23 @@ public final class ChatService: ChatServiceType, ObservableObject { } // MARK: - Helper Methods for Tool Call Status Updates + + /// Returns true if the `conversationId` belongs to the active conversation or any subagent conversations. + func isConversationIdValid(_ conversationId: String) -> Bool { + conversationTurnTracking.validConversationIds.contains(conversationId) + } + + /// Workaround: toolConfirmation request does not have parent turnId. + func parentTurnIdForTurnId(_ turnId: String) -> String? { + conversationTurnTracking.turnParentMap[turnId] + } + + func storePendingToolCallRequest(toolCallId: String, request: ToolCallRequest) { + pendingToolCallRequests[toolCallId] = request + } /// Sends the confirmation response (accept/dismiss) back to the server - private func sendToolConfirmationResponse(_ request: ToolCallRequest, accepted: Bool) { + func sendToolConfirmationResponse(_ request: ToolCallRequest, accepted: Bool) { let toolResult = LanguageModelToolConfirmationResult( result: accepted ? .Accept : .Dismiss ) diff --git a/Core/Sources/ChatService/Extensions/ChatService+FileEdit.swift b/Core/Sources/ChatService/Extensions/ChatService+FileEdit.swift index df1509ff..c901a341 100644 --- a/Core/Sources/ChatService/Extensions/ChatService+FileEdit.swift +++ b/Core/Sources/ChatService/Extensions/ChatService+FileEdit.swift @@ -25,7 +25,7 @@ extension ChatService { switch fileEdit.toolName { case .insertEditIntoFile: - InsertEditIntoFileTool.applyEdit(for: fileURL, content: fileEdit.originalContent, contextProvider: self) + InsertEditIntoFileTool.applyEdit(for: fileURL, content: fileEdit.originalContent) case .createFile: try CreateFileTool.undo(for: fileURL) default: diff --git a/Core/Sources/ChatService/ToolCalls/AutoApproval/AutoApprovalScope.swift b/Core/Sources/ChatService/ToolCalls/AutoApproval/AutoApprovalScope.swift new file mode 100644 index 00000000..2a7df4a9 --- /dev/null +++ b/Core/Sources/ChatService/ToolCalls/AutoApproval/AutoApprovalScope.swift @@ -0,0 +1,10 @@ +import Foundation + +public typealias ConversationID = String + +public enum AutoApprovalScope: Hashable { + case session(ConversationID) + // Future scopes: + // case workspace(String) + // case global +} diff --git a/Core/Sources/ChatService/ToolCalls/AutoApproval/MCPApprovalStorage.swift b/Core/Sources/ChatService/ToolCalls/AutoApproval/MCPApprovalStorage.swift new file mode 100644 index 00000000..4cbfe9ed --- /dev/null +++ b/Core/Sources/ChatService/ToolCalls/AutoApproval/MCPApprovalStorage.swift @@ -0,0 +1,60 @@ +import Foundation + +struct MCPApprovalStorage { + 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] = [:] + + mutating func allowTool(scope: AutoApprovalScope, serverName: String, toolName: String) { + guard case .session(let conversationId) = scope else { return } + let server = normalize(serverName) + let tool = normalize(toolName) + guard !conversationId.isEmpty, !server.isEmpty, !tool.isEmpty else { return } + + approvals[conversationId, default: ConversationApprovalState()] + .serverApprovals[server, default: ServerApprovalState()] + .allowedTools + .insert(tool) + } + + mutating func allowServer(scope: AutoApprovalScope, serverName: String) { + guard case .session(let conversationId) = scope else { return } + let server = normalize(serverName) + guard !conversationId.isEmpty, !server.isEmpty else { return } + + approvals[conversationId, default: ConversationApprovalState()] + .serverApprovals[server, default: ServerApprovalState()] + .isServerAllowed = true + } + + func isAllowed(scope: AutoApprovalScope, serverName: String, toolName: String) -> Bool { + guard case .session(let conversationId) = scope else { return false } + let server = normalize(serverName) + let tool = normalize(toolName) + guard !conversationId.isEmpty, !server.isEmpty, !tool.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) + } + + mutating func clear(scope: AutoApprovalScope) { + guard case .session(let conversationId) = scope else { return } + guard !conversationId.isEmpty else { return } + approvals.removeValue(forKey: conversationId) + } + + 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..b33f46da --- /dev/null +++ b/Core/Sources/ChatService/ToolCalls/AutoApproval/SensitiveFileApprovalStorage.swift @@ -0,0 +1,45 @@ +import Foundation + +struct SensitiveFileApprovalStorage { + private struct ToolApprovalState { + var allowedFiles: Set = [] + } + + private struct ConversationApprovalState { + var toolApprovals: [String: ToolApprovalState] = [:] + } + + /// Storage for session-scoped approvals. + private var approvals: [ConversationID: ConversationApprovalState] = [:] + + 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 !conversationId.isEmpty, !tool.isEmpty, !key.isEmpty else { return } + + approvals[conversationId, default: ConversationApprovalState()] + .toolApprovals[tool, default: ToolApprovalState()] + .allowedFiles + .insert(key) + } + + 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 approvals[conversationId]?.toolApprovals[tool]?.allowedFiles.contains(key) == true + } + + mutating func clear(scope: AutoApprovalScope) { + guard case .session(let conversationId) = scope else { return } + guard !conversationId.isEmpty else { return } + approvals.removeValue(forKey: conversationId) + } + + private func normalize(_ value: String) -> String { + value.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..23eea765 --- /dev/null +++ b/Core/Sources/ChatService/ToolCalls/AutoApproval/ToolAutoApprovalManager.swift @@ -0,0 +1,68 @@ +import Foundation + +public actor ToolAutoApprovalManager { + public static let shared = ToolAutoApprovalManager() + + public enum AutoApproval: Equatable, Sendable { + case mcpTool(conversationId: String, serverName: String, toolName: String) + case mcpServer(conversationId: String, serverName: String) + case sensitiveFile(conversationId: String, toolName: String, fileKey: String) + } + + private var mcpStorage = MCPApprovalStorage() + private var sensitiveFileStorage = SensitiveFileApprovalStorage() + + public init() {} + + public func approve(_ approval: AutoApproval) { + switch approval { + case let .mcpTool(conversationId, serverName, toolName): + allowMCPTool(conversationId: conversationId, serverName: serverName, toolName: toolName) + case let .mcpServer(conversationId, serverName): + allowMCPServer(conversationId: conversationId, serverName: serverName) + case let .sensitiveFile(conversationId, toolName, fileKey): + allowSensitiveFile(conversationId: conversationId, toolName: toolName, fileKey: fileKey) + } + } + + // 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: - 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: - Cleanup + + public func clearConversationData(conversationId: String?) { + guard let conversationId else { return } + mcpStorage.clear(scope: .session(conversationId)) + sensitiveFileStorage.clear(scope: .session(conversationId)) + } +} + diff --git a/Core/Sources/ChatService/ToolCalls/AutoApproval/ToolAutoApprovalParsingHelpers.swift b/Core/Sources/ChatService/ToolCalls/AutoApproval/ToolAutoApprovalParsingHelpers.swift new file mode 100644 index 00000000..38d68b6f --- /dev/null +++ b/Core/Sources/ChatService/ToolCalls/AutoApproval/ToolAutoApprovalParsingHelpers.swift @@ -0,0 +1,47 @@ +import Foundation + +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] + ) + + public nonisolated static func extractMCPServerName(from message: String) -> String? { + let fullRange = NSRange(message.startIndex..= 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 sensitiveFileKey(from message: String) -> String { + let fullRange = NSRange(message.startIndex..= 2, + let range = Range(match.range(at: 1), in: message) { + let description = String(message[range]).trimmingCharacters(in: .whitespacesAndNewlines) + if !description.isEmpty { + return description.lowercased() + } + } + + return "sensitive files" + } +} diff --git a/Core/Sources/ChatService/ToolCalls/ClientToolConfirmationEventHandler.swift b/Core/Sources/ChatService/ToolCalls/ClientToolConfirmationEventHandler.swift new file mode 100644 index 00000000..a55ab658 --- /dev/null +++ b/Core/Sources/ChatService/ToolCalls/ClientToolConfirmationEventHandler.swift @@ -0,0 +1,95 @@ +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 let mcpServerName { + let allowed = await ToolAutoApprovalManager.shared.isMCPAllowed( + conversationId: params.conversationId, + serverName: mcpServerName, + toolName: params.name + ) + + if allowed { + return true + } + } + + if ToolAutoApprovalManager.isSensitiveFileOperation(message: confirmationMessage) { + let fileKey = ToolAutoApprovalManager.sensitiveFileKey(from: confirmationMessage) + let allowed = await ToolAutoApprovalManager.shared.isSensitiveFileAllowed( + conversationId: params.conversationId, + toolName: params.name, + fileKey: fileKey + ) + + if allowed { + return true + } + } + + return false + } + + func makeEditAgentRounds(params: InvokeClientToolParams, status: AgentToolCall.ToolCallStatus) -> [AgentRound] { + [ + AgentRound( + roundId: params.roundId, + reply: "", + toolCalls: [ + AgentToolCall( + id: params.toolCallId, + name: params.name, + status: status, + invokeParams: params, + title: params.title + ) + ] + ) + ] + } +} diff --git a/Core/Sources/ChatService/ToolCalls/GetErrorsTool.swift b/Core/Sources/ChatService/ToolCalls/GetErrorsTool.swift index f41aa524..3a464016 100644 --- a/Core/Sources/ChatService/ToolCalls/GetErrorsTool.swift +++ b/Core/Sources/ChatService/ToolCalls/GetErrorsTool.swift @@ -33,9 +33,11 @@ public class GetErrorsTool: ICopilotTool { /// As the resolving should be sync. Especially when completion the JSONRPCResponse let focusedElement: AXUIElement? = try? xcodeInstance.appElement.copyValue(key: kAXFocusedUIElementAttribute) let focusedEditor: SourceEditor? - if let editorElement = focusedElement, editorElement.isSourceEditor { + if let editorElement = focusedElement, editorElement.isNonNavigatorSourceEditor { focusedEditor = .init(runningApplication: xcodeInstance.runningApplication, element: editorElement) - } else if let element = focusedElement, let editorElement = element.firstParent(where: \.isSourceEditor) { + } else if let element = focusedElement, let editorElement = element.firstParent( + where: \.isNonNavigatorSourceEditor + ) { focusedEditor = .init(runningApplication: xcodeInstance.runningApplication, element: editorElement) } else { focusedEditor = nil diff --git a/Core/Sources/ChatService/ToolCalls/InsertEditIntoFileTool.swift b/Core/Sources/ChatService/ToolCalls/InsertEditIntoFileTool.swift index a833fc4f..2eb6b160 100644 --- a/Core/Sources/ChatService/ToolCalls/InsertEditIntoFileTool.swift +++ b/Core/Sources/ChatService/ToolCalls/InsertEditIntoFileTool.swift @@ -7,12 +7,16 @@ 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 { @@ -24,6 +28,10 @@ public enum InsertEditError: LocalizedError { 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." } } } @@ -50,7 +58,7 @@ public class InsertEditIntoFileTool: ICopilotTool { let fileURL = URL(fileURLWithPath: filePath) let originalContent = try String(contentsOf: fileURL, encoding: .utf8) - InsertEditIntoFileTool.applyEdit(for: fileURL, content: code, contextProvider: contextProvider) { newContent, error in + InsertEditIntoFileTool.applyEdit(for: fileURL, content: code) { newContent, error in if let error = error { self.completeResponse( request, @@ -106,7 +114,6 @@ public class InsertEditIntoFileTool: ICopilotTool { public static func applyEdit( for fileURL: URL, content: String, - contextProvider: any ToolContextProvider, xcodeInstance: AppInstanceInspector ) throws -> String { guard let editorElement = Self.getEditorElement(by: xcodeInstance, for: fileURL) @@ -155,39 +162,21 @@ public class InsertEditIntoFileTool: ICopilotTool { public static func applyEdit( for fileURL: URL, content: String, - contextProvider: any ToolContextProvider, 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, - contextProvider: contextProvider, - 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)") - } + 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 + ) } } @@ -248,3 +237,73 @@ private extension AppInstanceInspector { 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/ToolCallStatusUpdater.swift b/Core/Sources/ChatService/ToolCalls/ToolCallStatusUpdater.swift index d6d86386..b8058ccb 100644 --- a/Core/Sources/ChatService/ToolCalls/ToolCallStatusUpdater.swift +++ b/Core/Sources/ChatService/ToolCalls/ToolCallStatusUpdater.swift @@ -39,6 +39,7 @@ struct ToolCallStatusUpdater { AgentToolCall( id: toolCallId, name: toolCall.name, + toolType: toolCall.toolType, status: newStatus ), ] @@ -65,6 +66,7 @@ struct ToolCallStatusUpdater { AgentToolCall( id: toolCallId, name: toolCall.name, + toolType: toolCall.toolType, status: newStatus ), ] diff --git a/Core/Sources/ConversationTab/Chat.swift b/Core/Sources/ConversationTab/Chat.swift index fc3e2c58..edb8fbc4 100644 --- a/Core/Sources/ConversationTab/Chat.swift +++ b/Core/Sources/ConversationTab/Chat.swift @@ -581,6 +581,7 @@ struct Chat { case copyCode(MessageID) case insertCode(String) case toolCallAccepted(String) + case toolCallAcceptedWithApproval(String, ToolAutoApprovalManager.AutoApproval?) case toolCallCompleted(String, String) case toolCallCancelled(String) @@ -760,6 +761,17 @@ struct Chat { return .run { _ in service.updateToolCallStatus(toolCallId: toolCallId, status: .accepted) }.cancellable(id: CancelID.sendMessage(self.id)) + + case let .toolCallAcceptedWithApproval(toolCallId, approval): + guard !toolCallId.isEmpty else { return .none } + return .run { send in + if let approval { + await ToolAutoApprovalManager.shared.approve(approval) + } + + await send(.toolCallAccepted(toolCallId)) + }.cancellable(id: CancelID.sendMessage(self.id)) + case let .toolCallCancelled(toolCallId): guard !toolCallId.isEmpty else { return .none } return .run { _ in diff --git a/Core/Sources/ConversationTab/Views/BotMessage.swift b/Core/Sources/ConversationTab/Views/BotMessage.swift index 0f526eb4..727b45f3 100644 --- a/Core/Sources/ConversationTab/Views/BotMessage.swift +++ b/Core/Sources/ConversationTab/Views/BotMessage.swift @@ -127,6 +127,14 @@ struct BotMessage: View { HStack { if shouldShowTurnStatus() { TurnStatusView(message: message) + .modify { view in + if message.turnStatus == .inProgress { + view + .scaledPadding(.leading, 6) + } else { + view + } + } } Spacer() @@ -256,8 +264,8 @@ private struct TurnStatusView: View { HStack(spacing: 4) { ProgressView() .controlSize(.small) - .scaledFont(size: chatFontSize - 1) - .conditionalFontWeight(.medium) + .scaledScaleEffect(0.7) + .scaledFrame(width: 16, height: 16) Text("Generating...") .scaledFont(size: chatFontSize - 1) diff --git a/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ConversationAgentProgressView.swift b/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ConversationAgentProgressView.swift index 91fa95df..76aa64d5 100644 --- a/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ConversationAgentProgressView.swift +++ b/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ConversationAgentProgressView.swift @@ -1,15 +1,15 @@ -import SwiftUI -import ConversationServiceProvider -import ComposableArchitecture -import Combine -import ChatTab import ChatService +import ChatTab +import Combine +import ComposableArchitecture +import ConversationServiceProvider import SharedUIComponents +import SwiftUI struct ProgressAgentRound: View { let rounds: [AgentRound] let chat: StoreOf - + var body: some View { WithPerceptionTracking { VStack(alignment: .leading, spacing: 8) { @@ -33,9 +33,9 @@ struct ProgressAgentRound: View { struct SubAgentRounds: View { let rounds: [AgentRound] let chat: StoreOf - + @Environment(\.colorScheme) var colorScheme - + var body: some View { WithPerceptionTracking { VStack(alignment: .leading, spacing: 8) { @@ -59,7 +59,7 @@ struct SubAgentRounds: View { struct ProgressToolCalls: View { let tools: [AgentToolCall] let chat: StoreOf - + var body: some View { WithPerceptionTracking { VStack(alignment: .leading, spacing: 4) { @@ -85,6 +85,139 @@ struct ToolConfirmationView: View { @AppStorage(\.chatFontSize) var chatFontSize + private var toolName: String { tool.name } + private var titleText: String { tool.title ?? "" } + private var mcpServerName: String? { ToolAutoApprovalManager.extractMCPServerName(from: titleText) } + private var conversationId: String { tool.invokeParams?.conversationId ?? "" } + private var invokeMessage: String { tool.invokeParams?.message ?? "" } + private var isSensitiveFileOperation: Bool { ToolAutoApprovalManager.isSensitiveFileOperation(message: invokeMessage) } + private var sensitiveFileKey: String { ToolAutoApprovalManager.sensitiveFileKey(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 #available(macOS 13.0, *) { + 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) + } + + @available(macOS 13.0, *) + private var sensitiveFileMenuItems: [SplitButtonMenuItem] { + [ + SplitButtonMenuItem(title: "Allow in this Session") { + chat.send( + .toolCallAcceptedWithApproval( + tool.id, + .sensitiveFile( + conversationId: conversationId, + toolName: toolName, + fileKey: sensitiveFileKey + ) + ) + ) + } + ] + } + + @available(macOS 13.0, *) + private var sensitiveFileSplitButton: some View { + SplitButton( + title: "Allow", + isDisabled: false, + primaryAction: { + chat.send(.toolCallAccepted(tool.id)) + }, + menuItems: sensitiveFileMenuItems, + style: .prominent + ) + } + + @available(macOS 13.0, *) + private func mcpMenuItems(serverName: String) -> [SplitButtonMenuItem] { + [ + SplitButtonMenuItem(title: "Allow \(toolName) in this session") { + chat.send( + .toolCallAcceptedWithApproval( + tool.id, + .mcpTool( + conversationId: conversationId, + serverName: serverName, + toolName: toolName + ) + ) + ) + }, + SplitButtonMenuItem(title: "Allow tools from \(serverName) in this session") { + chat.send( + .toolCallAcceptedWithApproval( + tool.id, + .mcpServer( + conversationId: conversationId, + serverName: serverName + ) + ) + ) + }, + ] + } + + @available(macOS 13.0, *) + private func mcpSplitButton(serverName: String) -> some View { + SplitButton( + title: "Allow", + isDisabled: false, + primaryAction: { + chat.send(.toolCallAccepted(tool.id)) + }, + menuItems: mcpMenuItems(serverName: serverName), + style: .prominent + ) + } + var body: some View { WithPerceptionTracking { VStack(alignment: .leading, spacing: 8) { @@ -104,15 +237,8 @@ struct ToolConfirmationView: View { Text(tool.isToolcallingLoopContinueTool ? "Cancel" : "Skip") .scaledFont(.body) } - - Button(action: { - chat.send(.toolCallAccepted(tool.id)) - }) { - Text(tool.isToolcallingLoopContinueTool ? "Continue" : "Allow") - .scaledFont(.body) - } - .buttonStyle(BorderedProminentButtonStyle()) - + + confirmationActionView } .frame(maxWidth: .infinity, alignment: .leading) .scaledPadding(.top, 4) @@ -132,7 +258,7 @@ struct ToolConfirmationTitleView: View { var fontWeight: Font.Weight = .regular @AppStorage(\.chatFontSize) var chatFontSize - + var body: some View { HStack(spacing: 4) { Text(title) @@ -156,12 +282,12 @@ struct GenericToolTitleView: View { HStack(spacing: 4) { Text(toolStatus) .textSelection(.enabled) - .scaledFont(size: chatFontSize, weight: fontWeight) + .scaledFont(size: chatFontSize - 1, weight: fontWeight) .foregroundStyle(.primary) .background(Color.clear) Text(toolName) .textSelection(.enabled) - .scaledFont(size: chatFontSize, weight: fontWeight) + .scaledFont(size: chatFontSize - 1, weight: fontWeight) .foregroundStyle(.primary) .scaledPadding(.vertical, 2) .scaledPadding(.horizontal, 4) @@ -190,9 +316,9 @@ struct ProgressAgentRound_Preview: PreviewProvider { id: "toolcall_002", name: "Tool Call 2", progressMessage: "Running Tool Call 2", - status: .running) - ]) - ] + status: .running), + ]), + ] static var previews: some View { let chatTabInfo = ChatTabInfo(id: "id", workspacePath: "path", username: "name") diff --git a/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ToolStatusItemView.swift b/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ToolStatusItemView.swift index cffcef97..9238a932 100644 --- a/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ToolStatusItemView.swift +++ b/Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ToolStatusItemView.swift @@ -9,6 +9,7 @@ struct ToolStatusItemView: View { let tool: AgentToolCall @AppStorage(\.chatFontSize) var chatFontSize + @AppStorage(\.fontScale) var fontScale @State private var isHoveringFileLink = false @@ -291,6 +292,31 @@ struct ToolStatusItemView: View { ) } + @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 @@ -440,6 +466,11 @@ struct ToolStatusItemView: View { 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, @@ -530,4 +561,20 @@ private extension View { } } } + + func toolCallDetailStyle(fontScale: CGFloat) -> some View { + /// Leverage the `modify` extension to avoid refreshing of chat panel `List` view + self.modify { view in + view + .foregroundColor(.secondary) + .scaledPadding(4) + .frame(maxWidth: .infinity, alignment: .leading) + .background(SecondarySystemFillColor) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .background( + RoundedRectangle(cornerRadius: 6) + .stroke(Color.agentToolStatusOutlineColor, lineWidth: 1 * fontScale) + ) + } + } } diff --git a/Core/Sources/HostApp/AdvancedSettings/ChatSection.swift b/Core/Sources/HostApp/AdvancedSettings/ChatSection.swift index 988a086e..86c61458 100644 --- a/Core/Sources/HostApp/AdvancedSettings/ChatSection.swift +++ b/Core/Sources/HostApp/AdvancedSettings/ChatSection.swift @@ -13,6 +13,8 @@ struct ChatSection: View { @AppStorage(\.autoAttachChatToXcode) var autoAttachChatToXcode @AppStorage(\.enableFixError) var enableFixError @AppStorage(\.enableSubagent) var enableSubagent + @AppStorage(\.enableAutoApproval) var enableAutoApproval + @AppStorage(\.trustToolAnnotations) var trustToolAnnotations @ObservedObject private var featureFlags = FeatureFlagManager.shared @ObservedObject private var copilotPolicy = CopilotPolicyManager.shared @@ -54,12 +56,7 @@ struct ChatSection: View { ), badge: copilotPolicy.isSubagentEnabled ? nil - : BadgeItem( - text: "Disabled by organization policy", - level: .warning, - icon: "exclamationmark.triangle.fill", - tooltip: "Subagents are disabled by your organization's policy. Please contact your administrator to enable them." - ) + : .disabledByPolicy(feature: "Subagents", isPlural: true) ) .disabled(!copilotPolicy.isSubagentEnabled) @@ -99,6 +96,15 @@ struct ChatSection: View { // Agent Max Tool Calling Requests AgentMaxToolCallLoopSetting() .padding(SettingsToggle.defaultPadding) + + Divider() + + // Auto Approval Toggles + AgentAutoApprovalSetting() + + Divider() + + AgentTrustToolAnnotationsSetting() } } } @@ -564,6 +570,66 @@ struct AgentFileSetting: View { } } +struct AgentAutoApprovalSetting: View { + @AppStorage(\.enableAutoApproval) var enableAutoApproval + @ObservedObject private var featureFlags = FeatureFlagManager.shared + @ObservedObject private var copilotPolicy = CopilotPolicyManager.shared + + var autoApprovalPolicyEnabled : Bool { + copilotPolicy.isAgentModeAutoApprovalEnabled && featureFlags.isEditorPreviewEnabled && featureFlags.isAgenModeAutoApprovalEnabled + } + + var body: some View { + WithPerceptionTracking { + SettingsToggle( + title: "Auto Approval", + subtitle: "Controls whether tool calls should be automatically approved.", + isOn: Binding( + get: { enableAutoApproval && autoApprovalPolicyEnabled }, + set: { if autoApprovalPolicyEnabled { enableAutoApproval = $0 } } + ), + badge: autoApprovalPolicyEnabled ? nil : .disabledByPolicy(feature: "Auto approval") + ) + .disabled(!autoApprovalPolicyEnabled) + .onChange(of: enableAutoApproval) { _ in + DistributedNotificationCenter + .default() + .post(name: .githubCopilotAgentAutoApprovalDidChange, object: nil) + } + } + } +} + +struct AgentTrustToolAnnotationsSetting: View { + @AppStorage(\.trustToolAnnotations) var trustToolAnnotations + @ObservedObject private var featureFlags = FeatureFlagManager.shared + @ObservedObject private var copilotPolicy = CopilotPolicyManager.shared + + var autoApprovalPolicyEnabled : Bool { + copilotPolicy.isAgentModeAutoApprovalEnabled && featureFlags.isEditorPreviewEnabled && featureFlags.isAgenModeAutoApprovalEnabled + } + + var body: some View { + WithPerceptionTracking { + 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: Binding( + get: { trustToolAnnotations && autoApprovalPolicyEnabled }, + set: { if autoApprovalPolicyEnabled { trustToolAnnotations = $0 } } + ), + badge: autoApprovalPolicyEnabled ? nil : .disabledByPolicy(feature: "Auto approval") + ) + .disabled(!autoApprovalPolicyEnabled) + .onChange(of: trustToolAnnotations) { _ in + DistributedNotificationCenter + .default() + .post(name: .githubCopilotAgentTrustToolAnnotationsDidChange, object: nil) + } + } + } +} + #Preview { ChatSection() .frame(width: 600) diff --git a/Core/Sources/HostApp/CopilotPolicyManager.swift b/Core/Sources/HostApp/CopilotPolicyManager.swift index cc769117..9cf22eff 100644 --- a/Core/Sources/HostApp/CopilotPolicyManager.swift +++ b/Core/Sources/HostApp/CopilotPolicyManager.swift @@ -17,7 +17,8 @@ public class CopilotPolicyManager: ObservableObject { @Published public private(set) var isCustomAgentEnabled = true @Published public private(set) var isSubagentEnabled = true @Published public private(set) var isCVERemediatorAgentEnabled = true - + @Published public private(set) var isAgentModeAutoApprovalEnabled = true + // MARK: - Private Properties private var cancellables = Set() @@ -74,7 +75,8 @@ public class CopilotPolicyManager: ObservableObject { isCustomAgentEnabled = policy.customAgentEnabled isSubagentEnabled = policy.subagentEnabled isCVERemediatorAgentEnabled = policy.cveRemediatorAgentEnabled - + isAgentModeAutoApprovalEnabled = policy.agentModeAutoApprovalEnabled + Logger.client.info("Copilot policy updated: customAgent=\(policy.customAgentEnabled), mcp=\(policy.mcpContributionPointEnabled), subagent=\(policy.subagentEnabled)") } catch { Logger.client.error("Failed to update copilot policy: \(error.localizedDescription)") diff --git a/Core/Sources/HostApp/FeatureFlagManager.swift b/Core/Sources/HostApp/FeatureFlagManager.swift index e996a8db..189d5a4e 100644 --- a/Core/Sources/HostApp/FeatureFlagManager.swift +++ b/Core/Sources/HostApp/FeatureFlagManager.swift @@ -19,7 +19,8 @@ public class FeatureFlagManager: ObservableObject { @Published public private(set) var isEditorPreviewEnabled = true @Published public private(set) var isChatEnabled = true @Published public private(set) var isCodeReviewEnabled = true - + @Published public private(set) var isAgenModeAutoApprovalEnabled = true + // MARK: - Private Properties private var cancellables = Set() @@ -78,7 +79,8 @@ public class FeatureFlagManager: ObservableObject { isEditorPreviewEnabled = featureFlags.editorPreviewFeatures isChatEnabled = featureFlags.chat isCodeReviewEnabled = featureFlags.ccr - + isAgenModeAutoApprovalEnabled = featureFlags.agentModeAutoApproval + Logger.client.info("Feature flags updated: agentMode=\(featureFlags.agentMode), byok=\(featureFlags.byok), mcp=\(featureFlags.mcp), editorPreview=\(featureFlags.editorPreviewFeatures)") } catch { Logger.client.error("Failed to update feature flags: \(error.localizedDescription)") diff --git a/Core/Sources/HostApp/SharedComponents/Badge.swift b/Core/Sources/HostApp/SharedComponents/Badge.swift index 011b5fed..7c0b2e03 100644 --- a/Core/Sources/HostApp/SharedComponents/Badge.swift +++ b/Core/Sources/HostApp/SharedComponents/Badge.swift @@ -105,3 +105,17 @@ struct Badge: View { .help(tooltip ?? text) } } + +extension BadgeItem { + static func disabledByPolicy(feature: String, isPlural: Bool = false) -> BadgeItem { + let verb = isPlural ? "are" : "is" + let pronoun = isPlural ? "them" : "it" + return .init( + text: "Disabled by organization policy", + level: .warning, + icon: "exclamationmark.triangle.fill", + tooltip: "\(feature) \(verb) disabled by your organization's policy. Please contact your administrator to enable \(pronoun)." + ) + } +} + diff --git a/Core/Sources/HostApp/SharedComponents/SplitButton.swift b/Core/Sources/HostApp/SharedComponents/SplitButton.swift deleted file mode 100644 index 3f9897fb..00000000 --- a/Core/Sources/HostApp/SharedComponents/SplitButton.swift +++ /dev/null @@ -1,116 +0,0 @@ -import SwiftUI -import AppKit - -// MARK: - SplitButton Menu Item - -public struct SplitButtonMenuItem: Identifiable { - public let id = UUID() - public let title: String - public let action: () -> Void - - public init(title: String, action: @escaping () -> Void) { - self.title = title - self.action = action - } -} - -// MARK: - SplitButton using NSComboButton - -@available(macOS 13.0, *) -public struct SplitButton: NSViewRepresentable { - let title: String - let primaryAction: () -> Void - let isDisabled: Bool - let menuItems: [SplitButtonMenuItem] - - public init( - title: String, - isDisabled: Bool = false, - primaryAction: @escaping () -> Void, - menuItems: [SplitButtonMenuItem] = [] - ) { - self.title = title - self.isDisabled = isDisabled - self.primaryAction = primaryAction - self.menuItems = menuItems - } - - public func makeNSView(context: Context) -> NSComboButton { - let button = NSComboButton() - - button.title = title - button.target = context.coordinator - button.action = #selector(Coordinator.handlePrimaryAction) - button.isEnabled = !isDisabled - - - context.coordinator.button = button - context.coordinator.updateMenu(with: menuItems) - - return button - } - - public func updateNSView(_ nsView: NSComboButton, context: Context) { - nsView.title = title - nsView.isEnabled = !isDisabled - context.coordinator.updateMenu(with: menuItems) - } - - public func makeCoordinator() -> Coordinator { - Coordinator(primaryAction: primaryAction) - } - - public class Coordinator: NSObject { - let primaryAction: () -> Void - weak var button: NSComboButton? - private var menuItemActions: [UUID: () -> Void] = [:] - - init(primaryAction: @escaping () -> Void) { - self.primaryAction = primaryAction - } - - @objc func handlePrimaryAction() { - primaryAction() - } - - @objc func handleMenuItemAction(_ sender: NSMenuItem) { - if let itemId = sender.representedObject as? UUID, - let action = menuItemActions[itemId] { - action() - } - } - - func updateMenu(with items: [SplitButtonMenuItem]) { - let menu = NSMenu() - menuItemActions.removeAll() - - // Add fixed menu title if there are items - if !items.isEmpty { - if #available(macOS 14.0, *) { - let headerItem = NSMenuItem.sectionHeader(title: "Install Server With") - menu.addItem(headerItem) - } else { - let headerItem = NSMenuItem() - headerItem.title = "Install Server With" - headerItem.isEnabled = false - menu.addItem(headerItem) - } - - // Add menu items - for item in items { - let menuItem = NSMenuItem( - title: item.title, - action: #selector(handleMenuItemAction(_:)), - keyEquivalent: "" - ) - menuItem.target = self - menuItem.representedObject = item.id - menuItemActions[item.id] = item.action - menu.addItem(menuItem) - } - } - - button?.menu = menu - } - } -} diff --git a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerDetailSheet.swift b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerDetailSheet.swift index e5533fc5..6c086ba6 100644 --- a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerDetailSheet.swift +++ b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerDetailSheet.swift @@ -7,7 +7,7 @@ import Foundation @available(macOS 13.0, *) struct MCPServerDetailSheet: View { let server: MCPRegistryServerDetail - let meta: ServerMeta + let meta: ServerMeta? @State private var selectedTab = TabType.Packages @State private var expandedPackages: Set = [] @State private var expandedRemotes: Set = [] @@ -101,8 +101,8 @@ struct MCPServerDetailSheet: View { Text(server.title ?? server.name) .font(.system(size: 18, weight: .semibold)) - if meta.official.status == .deprecated { - statusBadge(meta.official.status) + if let status = meta?.official?.status, status == .deprecated { + statusBadge(status) } Spacer() @@ -115,10 +115,14 @@ struct MCPServerDetailSheet: View { } .font(.system(size: 12, design: .monospaced)) .foregroundColor(.secondary) - - dateMetadataTag(title: "Published ", dateString: meta.official.publishedAt, image: "clock.arrow.trianglehead.counterclockwise.rotate.90") - dateMetadataTag(title: "Updated ", dateString: meta.official.updatedAt, image: "icloud.and.arrow.up") + if let publishedAt = meta?.official?.publishedAt { + dateMetadataTag(title: "Published ", dateString: publishedAt, image: "clock.arrow.trianglehead.counterclockwise.rotate.90") + } + + if let updatedAt = meta?.official?.updatedAt { + dateMetadataTag(title: "Updated ", dateString: updatedAt, image: "icloud.and.arrow.up") + } if let repo = server.repository, !repo.url.isEmpty, !repo.source.isEmpty { if let repoURL = URL(string: repo.url) { @@ -175,8 +179,10 @@ struct MCPServerDetailSheet: View { .tag(TabType.Packages) Text("Remotes (\(server.remotes?.count ?? 0))") .tag(TabType.Remotes) - Text("Metadata") - .tag(TabType.Metadata) + if meta?.official != nil { + Text("Metadata") + .tag(TabType.Metadata) + } } .pickerStyle(.segmented) } @@ -292,7 +298,9 @@ struct MCPServerDetailSheet: View { private var metadataTab: some View { VStack(alignment: .leading, spacing: 16) { - officialMetadataSection(meta.official) + if let officialMeta = meta?.official { + officialMetadataSection(officialMeta) + } } } @@ -304,18 +312,23 @@ struct MCPServerDetailSheet: View { } VStack(alignment: .leading, spacing: 8) { - metadataRow( - label: "Published", - value: parseDate(official.publishedAt) != nil ? formatExactDate( - parseDate(official.publishedAt)! - ) : official.publishedAt - ) - metadataRow( - label: "Updated", - value: parseDate(official.updatedAt) != nil ? formatExactDate( - parseDate(official.updatedAt)! - ) : official.updatedAt - ) + if let publishedAt = official.publishedAt { + metadataRow( + label: "Published", + value: parseDate(publishedAt) != nil ? formatExactDate( + parseDate(publishedAt)! + ) : publishedAt + ) + } + + if let updatedAt = official.updatedAt { + metadataRow( + label: "Updated", + value: parseDate(updatedAt) != nil ? formatExactDate( + parseDate(updatedAt)! + ) : updatedAt + ) + } } } .padding(16) diff --git a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerGalleryView.swift b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerGalleryView.swift index f8b7ba03..31e138fa 100644 --- a/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerGalleryView.swift +++ b/Core/Sources/HostApp/ToolsSettings/MCPRegistry/MCPServerGalleryView.swift @@ -297,13 +297,17 @@ struct MCPServerGalleryView: View { await viewModel.installServer(response.server) } }, - menuItems: viewModel.getInstallationOptions(for: response.server).map { option in - SplitButtonMenuItem(title: option.displayName) { - Task { - await viewModel.installServer(response.server, configuration: option.displayName) + menuItems: { + let options = viewModel.getInstallationOptions(for: response.server) + guard !options.isEmpty else { return [] } + return [SplitButtonMenuItem.header("Install Server With")] + options.map { option in + SplitButtonMenuItem(title: option.displayName) { + Task { + await viewModel.installServer(response.server, configuration: option.displayName) + } } } - } + }() ) .help("Install") } else { diff --git a/Core/Sources/Service/RealtimeSuggestionController.swift b/Core/Sources/Service/RealtimeSuggestionController.swift index 297031ec..b3fd109a 100644 --- a/Core/Sources/Service/RealtimeSuggestionController.swift +++ b/Core/Sources/Service/RealtimeSuggestionController.swift @@ -69,8 +69,8 @@ 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() } for await _ in valueChange { diff --git a/Core/Sources/SuggestionWidget/Extensions/Helper.swift b/Core/Sources/SuggestionWidget/Extensions/Helper.swift index 9e52c3c3..b59276f7 100644 --- a/Core/Sources/SuggestionWidget/Extensions/Helper.swift +++ b/Core/Sources/SuggestionWidget/Extensions/Helper.swift @@ -12,7 +12,7 @@ struct LocationStrategyHelper { with lines: [String], length: Int? = nil ) -> CGRect? { - guard editor.isSourceEditor, + guard editor.isNonNavigatorSourceEditor, lineNumber < lines.count && lineNumber >= 0 else { return nil diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift index e6f274b7..c28f6a66 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift @@ -1,12 +1,13 @@ import ActiveApplicationMonitor import AppKit +import ChatService import ChatTab import ComposableArchitecture -import GitHubCopilotService -import SwiftUI -import PersistMiddleware import ConversationTab +import GitHubCopilotService import HostAppActivator +import PersistMiddleware +import SwiftUI public enum ChatTabBuilderCollection: Equatable { case folder(title: String, kinds: [ChatTabKind]) @@ -29,7 +30,7 @@ public struct ChatTabKind: Equatable { public struct WorkspaceIdentifier: Hashable, Codable { public let path: String public let username: String - + public init(path: String, username: String) { self.path = path self.username = username @@ -66,7 +67,7 @@ public struct ChatHistory: Equatable { workspaces[index] = workspace } } - + mutating func addWorkspace(_ workspace: ChatWorkspace) { guard !workspaces.contains(where: { $0.id == workspace.id }) else { return } workspaces[id: workspace.id] = workspace @@ -84,10 +85,10 @@ public struct ChatWorkspace: Identifiable, Equatable { guard let tabId = selectedTabId else { return tabInfo.first } return tabInfo[id: tabId] } - - public var workspacePath: String { get { id.path} } - public var username: String { get { id.username } } - + + public var workspacePath: String { id.path } + public var username: String { id.username } + private var onTabInfoDeleted: (String) -> Void public init( @@ -103,29 +104,29 @@ public struct ChatWorkspace: Identifiable, Equatable { self.selectedTabId = selectedTabId self.onTabInfoDeleted = onTabInfoDeleted } - + /// Walkaround `Equatable` error for `onTabInfoDeleted` public static func == (lhs: ChatWorkspace, rhs: ChatWorkspace) -> Bool { lhs.id == rhs.id && - lhs.tabInfo == rhs.tabInfo && - lhs.tabCollection == rhs.tabCollection && - lhs.selectedTabId == rhs.selectedTabId + lhs.tabInfo == rhs.tabInfo && + lhs.tabCollection == rhs.tabCollection && + lhs.selectedTabId == rhs.selectedTabId } - + public mutating func applyLRULimit(maxSize: Int = 5) { guard tabInfo.count > maxSize else { return } - + // Tabs not selected let nonSelectedTabs = Array(tabInfo.filter { $0.id != selectedTabId }) let sortedByUpdatedAt = nonSelectedTabs.sorted { $0.updatedAt < $1.updatedAt } - + let tabsToRemove = Array(sortedByUpdatedAt.prefix(tabInfo.count - maxSize)) - + // Remove Tabs for tab in tabsToRemove { // destroy tab onTabInfoDeleted(tab.id) - + // remove from workspace tabInfo.remove(id: tab.id) } @@ -175,19 +176,19 @@ public struct ChatPanelFeature { // case switchToPreviousTab // case moveChatTab(from: Int, to: Int) case focusActiveChatTab - + // Chat History case chatHistoryItemClicked(id: String) case chatHistoryDeleteButtonClicked(id: String) case chatTab(id: String, action: ChatTabItem.Action) - + // persist case saveChatTabInfo([ChatTabInfo?], ChatWorkspace) case deleteChatTabInfo(id: String, ChatWorkspace) case restoreWorkspace(ChatWorkspace) - + case syncChatTabInfo([ChatTabInfo?]) - + // ChatWorkspace cleanup case scheduleLRUCleanup(ChatWorkspace) case performLRUCleanup(ChatWorkspace) @@ -207,7 +208,8 @@ public struct ChatPanelFeature { } public var body: some ReducerOf { - Reduce { state, action in + Reduce { + state, action in switch action { case .hideButtonClicked: state.isPanelDisplayed = false @@ -234,12 +236,13 @@ public struct ChatPanelFeature { return .none case .toggleChatPanelDetachedButtonClicked: - if state.isFullScreen, state.isDetached { + if state.isFullScreen, + state.isDetached { return .run { send in await send(.attachChatPanel) } } - + state.isDetached.toggle() return .none @@ -251,7 +254,7 @@ public struct ChatPanelFeature { if state.isFullScreen { return .run { send in await MainActor.run { toggleFullScreen() } - try await Task.sleep(nanoseconds: 1_000_000_000) + try await Task.sleep(nanoseconds: 1000000000) await send(.attachChatPanel) } } @@ -336,18 +339,23 @@ public struct ChatPanelFeature { } state.chatHistory.updateHistory(currentChatWorkspace) return .none - + case let .chatHistoryDeleteButtonClicked(id): // the current chat should not be deleted - guard var currentChatWorkspace = state.currentChatWorkspace, id != currentChatWorkspace.selectedTabId else { + guard var currentChatWorkspace = state.currentChatWorkspace, + id != currentChatWorkspace.selectedTabId else { return .none } + let CLSConversationID = currentChatWorkspace.tabInfo.first { + $0.id == id + }?.CLSConversationID currentChatWorkspace.tabInfo.removeAll { $0.id == id } state.chatHistory.updateHistory(currentChatWorkspace) - + let chatWorkspace = currentChatWorkspace return .run { send in await send(.deleteChatTabInfo(id: id, chatWorkspace)) + await ToolAutoApprovalManager.shared.clearConversationData(conversationId: CLSConversationID) } // case .createNewTapButtonHovered: @@ -356,11 +364,11 @@ public struct ChatPanelFeature { case .createNewTapButtonClicked: return .none // handled in GUI Reducer - - case .restoreTabByInfo(_): + + case .restoreTabByInfo: return .none // handled in GUI Reducer - - case .createNewTabByID(_): + + case .createNewTabByID: return .none // handled in GUI Reducer case let .tabClicked(id): @@ -369,37 +377,37 @@ public struct ChatPanelFeature { // chatTabGroup.selectedTabId = nil return .none } - + let (originalTab, currentTab) = currentChatWorkspace.switchTab(to: &chatTabInfo) state.chatHistory.updateHistory(currentChatWorkspace) - + let workspace = currentChatWorkspace return .run { send in await send(.focusActiveChatTab) await send(.saveChatTabInfo([originalTab, currentTab], workspace)) await send(.syncChatTabInfo([originalTab, currentTab])) } - + case let .chatHistoryItemClicked(id): guard var chatWorkspace = state.currentChatWorkspace, // No Need to swicth selected Tab when already selected id != chatWorkspace.selectedTabId else { return .none } - + // Try to find the tab in three places: // 1. In current workspace's open tabs let existingTab = chatWorkspace.tabInfo.first(where: { $0.id == id }) - + // 2. In persistent storage let storedTab = existingTab == nil ? ChatTabInfoStore.getByID(id, with: .init(workspacePath: chatWorkspace.workspacePath, username: chatWorkspace.username)) : nil - + if var tabInfo = existingTab ?? storedTab { // Tab found in workspace or storage - switch to it let (originalTab, currentTab) = chatWorkspace.switchTab(to: &tabInfo) state.chatHistory.updateHistory(chatWorkspace) - + let workspace = chatWorkspace let info = tabInfo return .run { send in @@ -407,20 +415,20 @@ public struct ChatPanelFeature { if storedTab != nil { await send(.restoreTabByInfo(info: info)) } - + // as converstaion tab is lazy restore // should restore tab when switching if let chatTab = chatTabPool.getTab(of: id), let conversationTab = chatTab as? ConversationTab { await conversationTab.restoreIfNeeded() } - + await send(.saveChatTabInfo([originalTab, currentTab], workspace)) - + await send(.syncChatTabInfo([originalTab, currentTab])) } } - + // 3. Tab not found - create a new one return .run { send in await send(.createNewTabByID(id: id)) @@ -428,13 +436,13 @@ public struct ChatPanelFeature { case var .appendAndSelectTab(tab): guard var chatWorkspace = state.currentChatWorkspace, - !chatWorkspace.tabInfo.contains(where: { $0.id == tab.id }) + !chatWorkspace.tabInfo.contains(where: { $0.id == tab.id }) else { return .none } - + chatWorkspace.tabInfo.append(tab) let (originalTab, currentTab) = chatWorkspace.switchTab(to: &tab) state.chatHistory.updateHistory(chatWorkspace) - + let currentChatWorkspace = chatWorkspace return .run { send in await send(.focusActiveChatTab) @@ -449,7 +457,7 @@ public struct ChatPanelFeature { targetWorkspace.tabInfo.append(tab) let (originalTab, currentTab) = targetWorkspace.switchTab(to: &tab) state.chatHistory.updateHistory(targetWorkspace) - + let currentChatWorkspace = targetWorkspace return .run { send in await send(.saveChatTabInfo([originalTab, currentTab], currentChatWorkspace)) @@ -514,92 +522,92 @@ public struct ChatPanelFeature { // } // MARK: - ChatTabItem action - + case let .chatTab(id, .tabContentUpdated): guard var currentChatWorkspace = state.currentChatWorkspace, var info = state.currentChatWorkspace?.tabInfo[id: id] else { return .none } - + info.updatedAt = .now currentChatWorkspace.tabInfo[id: id] = info state.chatHistory.updateHistory(currentChatWorkspace) - + let chatTabInfo = info let chatWorkspace = currentChatWorkspace return .run { send in await send(.saveChatTabInfo([chatTabInfo], chatWorkspace)) } - + case let .chatTab(id, .setCLSConversationID(CID)): guard var currentChatWorkspace = state.currentChatWorkspace, var info = state.currentChatWorkspace?.tabInfo[id: id] else { return .none } - + info.CLSConversationID = CID currentChatWorkspace.tabInfo[id: id] = info state.chatHistory.updateHistory(currentChatWorkspace) - + let chatTabInfo = info let chatWorkspace = currentChatWorkspace return .run { send in await send(.saveChatTabInfo([chatTabInfo], chatWorkspace)) } - + case let .chatTab(id, .updateTitle(title)): guard var currentChatWorkspace = state.currentChatWorkspace, var info = state.currentChatWorkspace?.tabInfo[id: id], !info.isTitleSet else { return .none } - + info.title = title info.updatedAt = .now currentChatWorkspace.tabInfo[id: id] = info state.chatHistory.updateHistory(currentChatWorkspace) - + let chatTabInfo = info let chatWorkspace = currentChatWorkspace return .run { send in await send(.saveChatTabInfo([chatTabInfo], chatWorkspace)) } - + case .chatTab: return .none - + // MARK: - Persist + case let .saveChatTabInfo(chatTabInfos, chatWorkspace): let toSaveInfo = chatTabInfos.compactMap { $0 } guard toSaveInfo.count > 0 else { return .none } let workspacePath = chatWorkspace.workspacePath let username = chatWorkspace.username - + return .run { _ in Task(priority: .background) { ChatTabInfoStore.saveAll(toSaveInfo, with: .init(workspacePath: workspacePath, username: username)) } } - + case let .deleteChatTabInfo(id, chatWorkspace): let workspacePath = chatWorkspace.workspacePath let username = chatWorkspace.username - + ChatTabInfoStore.delete(by: id, with: .init(workspacePath: workspacePath, username: username)) return .none case var .restoreWorkspace(chatWorkspace): // chat opened before finishing restoration if var existChatWorkspace = state.chatHistory.workspaces[id: chatWorkspace.id] { - if var selectedChatTabInfo = chatWorkspace.tabInfo.first(where: { $0.id == chatWorkspace.selectedTabId }) { // Keep the selection state when restoring selectedChatTabInfo.isSelected = true chatWorkspace.tabInfo[id: selectedChatTabInfo.id] = selectedChatTabInfo - + // Update the existing workspace's selected tab to match existChatWorkspace.selectedTabId = selectedChatTabInfo.id - + // merge tab info existChatWorkspace.tabInfo.append(contentsOf: chatWorkspace.tabInfo) state.chatHistory.updateHistory(existChatWorkspace) - + let chatTabInfo = selectedChatTabInfo let workspace = existChatWorkspace return .run { send in @@ -608,21 +616,21 @@ public struct ChatPanelFeature { await send(.scheduleLRUCleanup(workspace)) } } - + // merge tab info existChatWorkspace.tabInfo.append(contentsOf: chatWorkspace.tabInfo) state.chatHistory.updateHistory(existChatWorkspace) - + let workspace = existChatWorkspace return .run { send in await send(.scheduleLRUCleanup(workspace)) } } - + state.chatHistory.addWorkspace(chatWorkspace) return .none - - case .syncChatTabInfo(let tabInfos): + + case let .syncChatTabInfo(tabInfos): for tabInfo in tabInfos { guard let tabInfo = tabInfo else { continue } if let conversationTab = chatTabPool.getTab(of: tabInfo.id) as? ConversationTab { @@ -630,14 +638,15 @@ public struct ChatPanelFeature { } } return .none - + // MARK: - Clean up ChatWorkspace - case .scheduleLRUCleanup(let chatWorkspace): + + case let .scheduleLRUCleanup(chatWorkspace): return .run { send in await send(.performLRUCleanup(chatWorkspace)) }.cancellable(id: "lru-cleanup-\(chatWorkspace.id)", cancelInFlight: true) // apply built-in race condition prevention - - case .performLRUCleanup(var chatWorkspace): + + case var .performLRUCleanup(chatWorkspace): chatWorkspace.applyLRULimit() state.chatHistory.updateHistory(chatWorkspace) return .none @@ -650,7 +659,6 @@ public struct ChatPanelFeature { } extension ChatPanelFeature { - func restoreConversationTabIfNeeded(_ id: String) async { if let chatTab = chatTabPool.getTab(of: id), let conversationTab = chatTab as? ConversationTab { @@ -661,30 +669,30 @@ extension ChatPanelFeature { extension ChatWorkspace { public mutating func switchTab(to chatTabInfo: inout ChatTabInfo) -> (originalTab: ChatTabInfo?, currentTab: ChatTabInfo) { - guard self.selectedTabId != chatTabInfo.id else { return (nil, chatTabInfo) } - + guard selectedTabId != chatTabInfo.id else { return (nil, chatTabInfo) } + // get original selected tab info to update its isSelected - var originalTabInfo: ChatTabInfo? = nil - if self.selectedTabId != nil { - originalTabInfo = self.tabInfo[id: self.selectedTabId!] + var originalTabInfo: ChatTabInfo? + if selectedTabId != nil { + originalTabInfo = tabInfo[id: selectedTabId!] } // fresh selected info in chatWorksapce and tabInfo - self.selectedTabId = chatTabInfo.id + selectedTabId = chatTabInfo.id originalTabInfo?.isSelected = false chatTabInfo.isSelected = true - + // update tab back to chatWorkspace - let isNewTab = self.tabInfo[id: chatTabInfo.id] == nil - self.tabInfo[id: chatTabInfo.id] = chatTabInfo + let isNewTab = tabInfo[id: chatTabInfo.id] == nil + tabInfo[id: chatTabInfo.id] = chatTabInfo if isNewTab { applyLRULimit() } - + if let originalTabInfo { - self.tabInfo[id: originalTabInfo.id] = originalTabInfo + tabInfo[id: originalTabInfo.id] = originalTabInfo } - + return (originalTabInfo, chatTabInfo) } } diff --git a/README.md b/README.md index a0e3fbda..eea0b39a 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ # GitHub Copilot for Xcode -[GitHub Copilot](https://github.com/features/copilot) for Xcode is the leading AI coding assistant for Xcode developers, helping you code faster and smarter. Stay in flow with **inline completions** and get instant help through **chat support**—explaining code, answering questions, and suggesting improvements. When you need more, Copilot scales with advanced features like **Agent Mode, MCP Registry, Copilot Vision, Code Review, Custom Instructions, and more**, making your Xcode workflow more efficient and intelligent. - +[GitHub Copilot](https://github.com/features/copilot) for Xcode is the leading AI coding assistant for Swift, Objective-C and iOS/macOS development. It delivers intelligent Completions, Chat, and Code Review—plus advanced features like Agent Mode, Next Edit Suggestions, MCP Registry, and Copilot Vision to make Xcode development faster and smarter. ## Chat @@ -29,7 +28,7 @@ You can receive auto-complete type suggestions from GitHub Copilot either by sta - macOS 12+ - Xcode 8+ -- A GitHub Copilot subscription. To learn more, visit [https://github.com/features/copilot](https://github.com/features/copilot). +- A GitHub account ## Getting Started diff --git a/Server/package-lock.json b/Server/package-lock.json index 238ed6f4..3ef8124b 100644 --- a/Server/package-lock.json +++ b/Server/package-lock.json @@ -8,9 +8,9 @@ "name": "@github/copilot-xcode", "version": "0.0.1", "dependencies": { - "@github/copilot-language-server": "1.403.0", - "@github/copilot-language-server-darwin-arm64": "1.403.0", - "@github/copilot-language-server-darwin-x64": "1.403.0", + "@github/copilot-language-server": "1.406.0", + "@github/copilot-language-server-darwin-arm64": "1.406.0", + "@github/copilot-language-server-darwin-x64": "1.406.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "monaco-editor": "0.52.2" @@ -38,9 +38,9 @@ } }, "node_modules/@github/copilot-language-server": { - "version": "1.403.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.403.0.tgz", - "integrity": "sha512-ciPxnERqbbN9MRn2Ghaje17UH8cotLA7s9Lypqz9voStagBKUg5Nbiv5yjiGXm6j8e1OiE/BY0zhfLv3xFdOcw==", + "version": "1.406.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.406.0.tgz", + "integrity": "sha512-yzWayzR707xuAMGS77c3G7jv0z1F1hx7Aesvp0+qBbxUJHVsrSMjUImJMIL/vWJ+kfo49rfjiTTksaxjUk1ujw==", "license": "MIT", "dependencies": { "vscode-languageserver-protocol": "^3.17.5" @@ -49,17 +49,17 @@ "copilot-language-server": "dist/language-server.js" }, "optionalDependencies": { - "@github/copilot-language-server-darwin-arm64": "1.403.0", - "@github/copilot-language-server-darwin-x64": "1.403.0", - "@github/copilot-language-server-linux-arm64": "1.403.0", - "@github/copilot-language-server-linux-x64": "1.403.0", - "@github/copilot-language-server-win32-x64": "1.403.0" + "@github/copilot-language-server-darwin-arm64": "1.406.0", + "@github/copilot-language-server-darwin-x64": "1.406.0", + "@github/copilot-language-server-linux-arm64": "1.406.0", + "@github/copilot-language-server-linux-x64": "1.406.0", + "@github/copilot-language-server-win32-x64": "1.406.0" } }, "node_modules/@github/copilot-language-server-darwin-arm64": { - "version": "1.403.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-arm64/-/copilot-language-server-darwin-arm64-1.403.0.tgz", - "integrity": "sha512-LI1TkqehsWv38OEv8T0iI438X9nQJYDodlJ+WDyCwImw4m3wdIabe0qQvFUv68dPOtxU5kDv5by8Lm5Pjmigdg==", + "version": "1.406.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-arm64/-/copilot-language-server-darwin-arm64-1.406.0.tgz", + "integrity": "sha512-x8E0cmuLShhzUQCZV8TgOPzxNaz3nKEeo4J8Dv6jDCbeAzmu6fIpUPL+Tz6K0nJlN1rYA9iolyr71glHXGbbNQ==", "cpu": [ "arm64" ], @@ -69,9 +69,9 @@ ] }, "node_modules/@github/copilot-language-server-darwin-x64": { - "version": "1.403.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-x64/-/copilot-language-server-darwin-x64-1.403.0.tgz", - "integrity": "sha512-DP9D/IW1ldzRLVQRj99PemABNRkWvXMyYviwdKx083eZkl1qWkkDNO2NpyUSGjCpfBTruoGRey3/+Q/E0Ul9RA==", + "version": "1.406.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-x64/-/copilot-language-server-darwin-x64-1.406.0.tgz", + "integrity": "sha512-Sv9Yib5aHLlGzLFiskUMexWG6u2ZNTqH4At+ECfkJCT5IDlIQVftyqtvIA+YEttxIaUpq7tgS/9IJscM+yfL4g==", "cpu": [ "x64" ], @@ -81,9 +81,9 @@ ] }, "node_modules/@github/copilot-language-server-linux-arm64": { - "version": "1.403.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-arm64/-/copilot-language-server-linux-arm64-1.403.0.tgz", - "integrity": "sha512-dQQzqemUbO7lKTtQszfKPLkedwvLl2VdBycmCIGE4vTNi6K+DD4c3Lx94xYMGnKvarzEvCNk/FtBfixlAUxleQ==", + "version": "1.406.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-arm64/-/copilot-language-server-linux-arm64-1.406.0.tgz", + "integrity": "sha512-I4N+j6BsNSJ4JGX4sBmXNRD3ZbXmuYXb20lGgn3rubCfnjHyyQnv99i3Z/LWV3rqYvyYxZYExfE+7nwKaEhPzw==", "cpu": [ "arm64" ], @@ -94,9 +94,9 @@ ] }, "node_modules/@github/copilot-language-server-linux-x64": { - "version": "1.403.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-x64/-/copilot-language-server-linux-x64-1.403.0.tgz", - "integrity": "sha512-INJyxAKSCFxeGae/OnpsdxIgbvtCN/aGM8UpVnEBjwWeQOhbPvZYLnvu0Xvqt0cs2EYJ8hhosti6oWqU6m/HRg==", + "version": "1.406.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-x64/-/copilot-language-server-linux-x64-1.406.0.tgz", + "integrity": "sha512-3DIE324Qu+bNfq0AzMH6SfSNngUqbNzS9M4B11iib/UWCVlVPCEAgW5TRRU8lWxyYzUdBNO4BGXSk/OXsvGj5Q==", "cpu": [ "x64" ], @@ -107,9 +107,9 @@ ] }, "node_modules/@github/copilot-language-server-win32-x64": { - "version": "1.403.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-win32-x64/-/copilot-language-server-win32-x64-1.403.0.tgz", - "integrity": "sha512-mXmLFDNYybejDC9XcPHpIxr8BCNaXs5U0danfIXHuOQ497c7hycQvxtsZcka1VsFhE0IB7BVsoD9N3ieFCAMuA==", + "version": "1.406.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-win32-x64/-/copilot-language-server-win32-x64-1.406.0.tgz", + "integrity": "sha512-WnoePb7je9/JUwfPuqQBwscMVo4HYTONKQEIhiIfdV4i4e+hU8oPrIMvJqgEtR8bVE3n+mkvGfYs0qRujYRh2A==", "cpu": [ "x64" ], diff --git a/Server/package.json b/Server/package.json index b862ef9d..727782a2 100644 --- a/Server/package.json +++ b/Server/package.json @@ -7,9 +7,9 @@ "build": "webpack" }, "dependencies": { - "@github/copilot-language-server": "1.403.0", - "@github/copilot-language-server-darwin-arm64": "1.403.0", - "@github/copilot-language-server-darwin-x64": "1.403.0", + "@github/copilot-language-server": "1.406.0", + "@github/copilot-language-server-darwin-arm64": "1.406.0", + "@github/copilot-language-server-darwin-x64": "1.406.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "monaco-editor": "0.52.2" diff --git a/Tool/Sources/AXExtension/AXUIElement+Xcode.swift b/Tool/Sources/AXExtension/AXUIElement+Xcode.swift index d9b1da9c..ff5948cc 100644 --- a/Tool/Sources/AXExtension/AXUIElement+Xcode.swift +++ b/Tool/Sources/AXExtension/AXUIElement+Xcode.swift @@ -28,4 +28,16 @@ public extension AXUIElement { var isXcodeMenuBar: Bool { ["menu bar", "menu bar item"].contains(self.description) } + + var isNavigator: Bool { + description == "navigator" + } + + var isDescendantOfNavigator: Bool { + self.firstParent(where: \.isNavigator) != nil + } + + var isNonNavigatorSourceEditor: Bool { + isSourceEditor && !isDescendantOfNavigator + } } diff --git a/Tool/Sources/AXExtension/AXUIElement.swift b/Tool/Sources/AXExtension/AXUIElement.swift index e9b9ed3b..677a8264 100644 --- a/Tool/Sources/AXExtension/AXUIElement.swift +++ b/Tool/Sources/AXExtension/AXUIElement.swift @@ -235,16 +235,16 @@ public extension AXUIElement { } func retrieveSourceEditor() -> AXUIElement? { - if self.isSourceEditor { return self } + if isNonNavigatorSourceEditor { return self } if self.isXcodeWorkspaceWindow { - return self.firstChild(where: \.isSourceEditor) + return self.firstChild(where: \.isNonNavigatorSourceEditor) } guard let xcodeWorkspaceWindowElement = self.firstParent(where: \.isXcodeWorkspaceWindow) else { return nil } - return xcodeWorkspaceWindowElement.firstChild(where: \.isSourceEditor) + return xcodeWorkspaceWindowElement.firstChild(where: \.isNonNavigatorSourceEditor) } } @@ -327,24 +327,24 @@ public extension AXUIElement { func findSourceEditorElement(shouldRetry: Bool = true) -> AXUIElement? { // 1. Check if the current element is a source editor - if isSourceEditor { + if isNonNavigatorSourceEditor { return self } // 2. Search for child that is a source editor - if let sourceEditorChild = firstChild(where: \.isSourceEditor) { + if let sourceEditorChild = firstChild(where: \.isNonNavigatorSourceEditor) { return sourceEditorChild } // 3. Search for parent that is a source editor (XcodeInspector's approach) - if let sourceEditorParent = firstParent(where: \.isSourceEditor) { + if let sourceEditorParent = firstParent(where: \.isNonNavigatorSourceEditor) { return sourceEditorParent } // 4. Search for parent that is an editor area if let editorAreaParent = firstParent(where: \.isEditorArea) { // 3.1 Search for child that is a source editor - if let sourceEditorChild = editorAreaParent.firstChild(where: \.isSourceEditor) { + if let sourceEditorChild = editorAreaParent.firstChild(where: \.isNonNavigatorSourceEditor) { return sourceEditorChild } } @@ -354,7 +354,7 @@ public extension AXUIElement { // 4.1 Search for child that is an editor area if let editorAreaChild = xcodeWorkspaceWindowParent.firstChild(where: \.isEditorArea) { // 4.2 Search for child that is a source editor - if let sourceEditorChild = editorAreaChild.firstChild(where: \.isSourceEditor) { + if let sourceEditorChild = editorAreaChild.firstChild(where: \.isNonNavigatorSourceEditor) { return sourceEditorChild } } diff --git a/Tool/Sources/AXHelper/AXHelper.swift b/Tool/Sources/AXHelper/AXHelper.swift index 7f44ef1a..533a3c1e 100644 --- a/Tool/Sources/AXHelper/AXHelper.swift +++ b/Tool/Sources/AXHelper/AXHelper.swift @@ -100,7 +100,7 @@ public struct AXHelper { } public static func scrollSourceEditorToLine(_ lineNumber: Int, content: String, focusedElement: AXUIElement) { - guard focusedElement.isSourceEditor, + guard focusedElement.isNonNavigatorSourceEditor, let scrollBar = focusedElement.parent?.verticalScrollBar, let linePosition = Self.getScrollPositionForLine(lineNumber, content: content) else { return } diff --git a/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift b/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift index bacc4989..d988a91e 100644 --- a/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift +++ b/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift @@ -35,9 +35,18 @@ public extension ChatMemory { for newToolCall in messageToolCalls { if let toolCallIndex = mergedToolCalls.firstIndex(where: { $0.id == newToolCall.id }) { mergedToolCalls[toolCallIndex].status = newToolCall.status + if let toolType = newToolCall.toolType { + mergedToolCalls[toolCallIndex].toolType = toolType + } if let progressMessage = newToolCall.progressMessage, !progressMessage.isEmpty { mergedToolCalls[toolCallIndex].progressMessage = progressMessage } + if let input = newToolCall.input, !input.isEmpty { + mergedToolCalls[toolCallIndex].input = input + } + if let inputMessage = newToolCall.inputMessage, !inputMessage.isEmpty { + mergedToolCalls[toolCallIndex].inputMessage = inputMessage + } if let result = newToolCall.result, !result.isEmpty { mergedToolCalls[toolCallIndex].result = result } @@ -163,9 +172,18 @@ extension ChatMessage { for newToolCall in newRound.toolCalls! { if let toolCallIndex = mergedToolCalls.firstIndex(where: { $0.id == newToolCall.id }) { mergedToolCalls[toolCallIndex].status = newToolCall.status + if let toolType = newToolCall.toolType { + mergedToolCalls[toolCallIndex].toolType = toolType + } if let progressMessage = newToolCall.progressMessage, !progressMessage.isEmpty { mergedToolCalls[toolCallIndex].progressMessage = newToolCall.progressMessage } + if let input = newToolCall.input, !input.isEmpty { + mergedToolCalls[toolCallIndex].input = input + } + if let inputMessage = newToolCall.inputMessage, !inputMessage.isEmpty { + mergedToolCalls[toolCallIndex].inputMessage = inputMessage + } if let result = newToolCall.result, !result.isEmpty { mergedToolCalls[toolCallIndex].result = result } diff --git a/Tool/Sources/ConversationServiceProvider/LSPTypes+AgentRound.swift b/Tool/Sources/ConversationServiceProvider/LSPTypes+AgentRound.swift index 86f0fc7e..69124626 100644 --- a/Tool/Sources/ConversationServiceProvider/LSPTypes+AgentRound.swift +++ b/Tool/Sources/ConversationServiceProvider/LSPTypes+AgentRound.swift @@ -2,7 +2,6 @@ import CopilotForXcodeKit import Foundation import LanguageServerProtocol - public struct AgentRound: Codable, Equatable { public let roundId: Int public var reply: String @@ -20,10 +19,11 @@ public struct AgentRound: Codable, Equatable { public struct AgentToolCall: Codable, Equatable, Identifiable { public let id: String public let name: String + public var toolType: ToolType? public var progressMessage: String? public var status: ToolCallStatus public var input: [String: AnyCodable]? - public let inputMessage: String? + public var inputMessage: String? public var error: String? public var result: [ToolCallResultData]? public var resultDetails: [ToolResultItem]? @@ -37,6 +37,7 @@ public struct AgentToolCall: Codable, Equatable, Identifiable { public init( id: String, name: String, + toolType: ToolType? = nil, progressMessage: String? = nil, status: ToolCallStatus, input: [String: AnyCodable]? = nil, @@ -49,6 +50,7 @@ public struct AgentToolCall: Codable, Equatable, Identifiable { ) { self.id = id self.name = name + self.toolType = toolType self.progressMessage = progressMessage self.status = status self.input = input diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift index 875c0666..57d9c0ef 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift @@ -98,7 +98,13 @@ public func editorConfiguration(includeMCP: Bool) -> JSONValue { let agentMaxToolCallingLoop = Double(UserDefaults.shared.value(for: \.agentMaxToolCallingLoop)) d["maxToolCallingLoop"] = .number(agentMaxToolCallingLoop) - + + let enableAutoApproval = UserDefaults.shared.value(for: \.enableAutoApproval) + d["toolConfirmAutoApprove"] = .bool(enableAutoApproval) + + let trustToolAnnotations = UserDefaults.shared.value(for: \.trustToolAnnotations) + d["trustToolAnnotations"] = .bool(trustToolAnnotations) + return .hash(d) } diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCPRegistry.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCPRegistry.swift index 00576e6d..fd1c8bf6 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCPRegistry.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/MCPRegistry.swift @@ -506,16 +506,16 @@ public enum ServerStatus: String, Codable { } public struct OfficialMeta: Codable { - public let status: ServerStatus - public let publishedAt: String - public let updatedAt: String - public let isLatest: Bool + public let status: ServerStatus? + public let publishedAt: String? + public let updatedAt: String? + public let isLatest: Bool? public init( - status: ServerStatus, - publishedAt: String, - updatedAt: String, - isLatest: Bool + status: ServerStatus? = nil, + publishedAt: String? = nil, + updatedAt: String? = nil, + isLatest: Bool? = nil ) { self.status = status self.publishedAt = publishedAt @@ -566,7 +566,7 @@ public struct MCPRegistryExtensionMeta: Codable { } public struct ServerMeta: Codable { - public let official: OfficialMeta + public let official: OfficialMeta? private let additionalProperties: [String: AnyCodable]? enum CodingKeys: String, CodingKey { @@ -574,7 +574,7 @@ public struct ServerMeta: Codable { } public init( - official: OfficialMeta, + official: OfficialMeta? = nil, additionalProperties: [String: AnyCodable]? = nil ) { self.official = official @@ -688,9 +688,9 @@ public struct MCPRegistryServerDetail: Codable { public struct MCPRegistryServerResponse : Codable { public let server: MCPRegistryServerDetail - public let meta: ServerMeta + public let meta: ServerMeta? - public init(server: MCPRegistryServerDetail, meta: ServerMeta) { + public init(server: MCPRegistryServerDetail, meta: ServerMeta? = nil) { self.server = server self.meta = meta } diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift index 6259323f..cc2521ff 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift @@ -166,6 +166,10 @@ public extension Notification.Name { .Name("com.github.CopilotForXcode.GitHubCopilotShouldRefreshEditorInformation") static let githubCopilotAgentMaxToolCallingLoopDidChange = Notification .Name("com.github.CopilotForXcode.GithubCopilotAgentMaxToolCallingLoopDidChange") + static let githubCopilotAgentAutoApprovalDidChange = Notification + .Name("com.github.CopilotForXcode.GithubCopilotAgentAutoApprovalDidChange") + static let githubCopilotAgentTrustToolAnnotationsDidChange = Notification + .Name("com.github.CopilotForXcode.GithubCopilotAgentTrustToolAnnotationsDidChange") } public class GitHubCopilotBaseService { @@ -537,7 +541,7 @@ public final class GitHubCopilotService: do { let completions = try await self .sendRequest(GitHubCopilotRequest.InlineCompletion(doc: .init( - textDocument: .init(uri: fileURL.absoluteString, version: 1), + textDocument: .init(uri: fileURL.absoluteString, version: 0), position: cursorPosition, formattingOptions: .init( tabSize: tabSize, @@ -584,37 +588,12 @@ public final class GitHubCopilotService: } } - func recoverContent() async { - try? await notifyChangeTextDocument( - fileURL: fileURL, - content: originalContent, - version: 0 - ) - } - - // since when the language server is no longer using the passed in content to generate - // suggestions, we will need to update the content to the file before we do any request. - // - // And sometimes the language server's content was not up to date and may generate - // weird result when the cursor position exceeds the line. let task = Task { @GitHubCopilotSuggestionActor in - try? await notifyChangeTextDocument( - fileURL: fileURL, - content: content, - version: 1 - ) - do { let maxTry: Int = 5 try Task.checkCancellation() return try await sendRequest(maxTry: maxTry) - } catch let error as CancellationError { - if ongoingTasks.isEmpty { - await recoverContent() - } - throw error } catch { - await recoverContent() throw error } } @@ -636,16 +615,10 @@ public final class GitHubCopilotService: await localProcessServer?.cancelOngoingTasks() do { - try? await notifyChangeTextDocument( - fileURL: fileURL, - content: content, - version: 1 - ) - let completions = try await sendRequest( GitHubCopilotRequest.CopilotInlineEdit( params: CopilotInlineEditsParams( - textDocument: .init(uri: fileURL.absoluteString, version: 1), + textDocument: .init(uri: fileURL.absoluteString, version: 0), position: cursorPosition ) )) @@ -1486,12 +1459,26 @@ public final class GitHubCopilotService: await sendConfigurationUpdate() // Combine both notification streams - let combinedNotifications = Publishers.Merge3( - NotificationCenter.default.publisher(for: .gitHubCopilotShouldRefreshEditorInformation).map { _ in "editorInfo" }, - FeatureFlagNotifierImpl.shared.featureFlagsDidChange.map { _ in "featureFlags" }, + let combinedNotifications = Publishers.MergeMany( + NotificationCenter.default + .publisher(for: .gitHubCopilotShouldRefreshEditorInformation) + .map { _ in "editorInfo" } + .eraseToAnyPublisher(), + FeatureFlagNotifierImpl.shared.featureFlagsDidChange + .map { _ in "featureFlags" } + .eraseToAnyPublisher(), DistributedNotificationCenter.default() .publisher(for: .githubCopilotAgentMaxToolCallingLoopDidChange) .map { _ in "agentMaxToolCallingLoop" } + .eraseToAnyPublisher(), + DistributedNotificationCenter.default() + .publisher(for: .githubCopilotAgentAutoApprovalDidChange) + .map { _ in "agentAutoApproval" } + .eraseToAnyPublisher(), + DistributedNotificationCenter.default() + .publisher(for: .githubCopilotAgentTrustToolAnnotationsDidChange) + .map { _ in "agentTrustToolAnnotations" } + .eraseToAnyPublisher() ) for await _ in combinedNotifications.values { diff --git a/Tool/Sources/GitHubCopilotService/Services/CopilotPolicyNotifier.swift b/Tool/Sources/GitHubCopilotService/Services/CopilotPolicyNotifier.swift index e1d07f4c..5072ae12 100644 --- a/Tool/Sources/GitHubCopilotService/Services/CopilotPolicyNotifier.swift +++ b/Tool/Sources/GitHubCopilotService/Services/CopilotPolicyNotifier.swift @@ -12,12 +12,14 @@ public struct CopilotPolicy: Hashable, Codable { public var customAgentEnabled: Bool = true public var subagentEnabled: Bool = true public var cveRemediatorAgentEnabled: Bool = true + public var agentModeAutoApprovalEnabled: Bool = false enum CodingKeys: String, CodingKey { case mcpContributionPointEnabled = "mcp.contributionPoint.enabled" case customAgentEnabled = "customAgent.enabled" case subagentEnabled = "subagent.enabled" case cveRemediatorAgentEnabled = "cveRemediatorAgent.enabled" + case agentModeAutoApprovalEnabled = "agentMode.autoApproval.enabled" } } diff --git a/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift b/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift index 8b343d61..fe08a348 100644 --- a/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift +++ b/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift @@ -35,7 +35,8 @@ public struct FeatureFlags: Hashable, Codable { public var byok: Bool public var editorPreviewFeatures: Bool public var activeExperimentForFeatureFlags: ActiveExperimentForFeatureFlags - + public var agentModeAutoApproval: Bool + public init( restrictedTelemetry: Bool = true, snippy: Bool = true, @@ -47,7 +48,8 @@ public struct FeatureFlags: Hashable, Codable { ccr: Bool = true, byok: Bool = true, editorPreviewFeatures: Bool = true, - activeExperimentForFeatureFlags: ActiveExperimentForFeatureFlags = [:] + activeExperimentForFeatureFlags: ActiveExperimentForFeatureFlags = [:], + agentModeAutoApproval: Bool = true ) { self.restrictedTelemetry = restrictedTelemetry self.snippy = snippy @@ -60,6 +62,7 @@ public struct FeatureFlags: Hashable, Codable { self.byok = byok self.editorPreviewFeatures = editorPreviewFeatures self.activeExperimentForFeatureFlags = activeExperimentForFeatureFlags + self.agentModeAutoApproval = agentModeAutoApproval } } @@ -103,6 +106,7 @@ public class FeatureFlagNotifierImpl: FeatureFlagNotifier { self.featureFlags.byok = self.didChangeFeatureFlagsParams.byok != false self.featureFlags.editorPreviewFeatures = self.didChangeFeatureFlagsParams.token["editor_preview_features"] != "0" self.featureFlags.activeExperimentForFeatureFlags = self.didChangeFeatureFlagsParams.activeExps + self.featureFlags.agentModeAutoApproval = self.didChangeFeatureFlagsParams.token["agent_mode_auto_approval"] != "0" } public func handleFeatureFlagNotification(_ didChangeFeatureFlagsParams: DidChangeFeatureFlagsParams) { diff --git a/Tool/Sources/Preferences/Keys.swift b/Tool/Sources/Preferences/Keys.swift index 93f8a80b..63b9f555 100644 --- a/Tool/Sources/Preferences/Keys.swift +++ b/Tool/Sources/Preferences/Keys.swift @@ -336,6 +336,14 @@ public extension UserDefaultPreferenceKeys { var enableSubagent: PreferenceKey { .init(defaultValue: true, key: "EnableSubagent") } + + var enableAutoApproval: PreferenceKey { + .init(defaultValue: false, key: "EnableAutoApproval") + } + + var trustToolAnnotations: PreferenceKey { + .init(defaultValue: false, key: "TrustToolAnnotations") + } } // MARK: - Theme diff --git a/Tool/Sources/Preferences/UserDefaults.swift b/Tool/Sources/Preferences/UserDefaults.swift index d322f321..b89aa5db 100644 --- a/Tool/Sources/Preferences/UserDefaults.swift +++ b/Tool/Sources/Preferences/UserDefaults.swift @@ -19,6 +19,8 @@ public extension UserDefaults { shared.setupDefaultValue(for: \.autoAttachChatToXcode) shared.setupDefaultValue(for: \.enableFixError) shared.setupDefaultValue(for: \.enableSubagent) + shared.setupDefaultValue(for: \.enableAutoApproval) + shared.setupDefaultValue(for: \.trustToolAnnotations) shared.setupDefaultValue(for: \.widgetColorScheme) shared.setupDefaultValue(for: \.customCommands) shared.setupDefaultValue( diff --git a/Tool/Sources/SharedUIComponents/SplitButton.swift b/Tool/Sources/SharedUIComponents/SplitButton.swift new file mode 100644 index 00000000..8ddead7b --- /dev/null +++ b/Tool/Sources/SharedUIComponents/SplitButton.swift @@ -0,0 +1,292 @@ +import SwiftUI +import AppKit + +// MARK: - SplitButton Menu Item + +public struct SplitButtonMenuItem: Identifiable { + public enum Kind { + case action(() -> Void) + case divider + case header + } + + public let id: UUID + public let title: String + public let kind: Kind + + public init(title: String, action: @escaping () -> Void) { + self.id = UUID() + self.title = title + self.kind = .action(action) + } + + private init(id: UUID = UUID(), title: String, kind: Kind) { + self.id = id + self.title = title + self.kind = kind + } + + public static func divider(id: UUID = UUID()) -> SplitButtonMenuItem { + .init(id: id, title: "", kind: .divider) + } + + public static func header(_ title: String, id: UUID = UUID()) -> SplitButtonMenuItem { + .init(id: id, title: title, kind: .header) + } +} + +@available(macOS 13.0, *) +private enum SplitButtonMenuBuilder { + static func buildMenu( + items: [SplitButtonMenuItem], + pullsDownCoverItem: Bool, + target: NSObject, + action: Selector, + menuItemActions: inout [UUID: () -> Void] + ) -> NSMenu { + let menu = NSMenu() + menu.autoenablesItems = false + menuItemActions.removeAll() + + if pullsDownCoverItem { + // First item is the "cover" item for pullsDown + menu.addItem(NSMenuItem(title: "", action: nil, keyEquivalent: "")) + } + + for item in items { + switch item.kind { + case .divider: + menu.addItem(.separator()) + + case .header: + if #available(macOS 14.0, *) { + menu.addItem(NSMenuItem.sectionHeader(title: item.title)) + } else { + let headerItem = NSMenuItem() + headerItem.title = item.title + headerItem.isEnabled = false + menu.addItem(headerItem) + } + + case .action(let handler): + let menuItem = NSMenuItem( + title: item.title, + action: action, + keyEquivalent: "" + ) + menuItem.target = target + menuItem.representedObject = item.id + menuItemActions[item.id] = handler + menu.addItem(menuItem) + } + } + + return menu + } +} + +// MARK: - SplitButton using NSComboButton + +@available(macOS 13.0, *) +public struct SplitButton: View { + let title: String + let primaryAction: () -> Void + let isDisabled: Bool + let menuItems: [SplitButtonMenuItem] + var style: SplitButtonStyle + + @AppStorage(\.fontScale) private var fontScale + + public enum SplitButtonStyle { + case standard + case prominent + } + + public init( + title: String, + isDisabled: Bool = false, + primaryAction: @escaping () -> Void, + menuItems: [SplitButtonMenuItem] = [], + style: SplitButtonStyle = .standard + ) { + self.title = title + self.isDisabled = isDisabled + self.primaryAction = primaryAction + self.menuItems = menuItems + self.style = style + } + + public var body: some View { + switch style { + case .standard: + SplitButtonRepresentable( + title: title, + isDisabled: isDisabled, + primaryAction: primaryAction, + menuItems: menuItems + ) + case .prominent: + HStack(spacing: 0) { + Button(action: primaryAction) { + Text(title) + .scaledFont(.body) + .padding(.horizontal, 6) + .padding(.vertical, 4) + } + .buttonStyle(.borderless) + + Rectangle() + .fill(Color.white.opacity(0.2)) + .frame(width: fontScale) + .padding(.vertical, 4) + + ProminentMenuButton( + menuItems: menuItems, + isDisabled: isDisabled + ) + .frame(width: 16) + } + .background(Color.accentColor) + .foregroundColor(.white) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .disabled(isDisabled) + .opacity(isDisabled ? 0.5 : 1) + } + } +} + +@available(macOS 13.0, *) +private struct ProminentMenuButton: NSViewRepresentable { + let menuItems: [SplitButtonMenuItem] + let isDisabled: Bool + + func makeNSView(context: Context) -> NSPopUpButton { + let button = NSPopUpButton(frame: .zero, pullsDown: true) + button.bezelStyle = .smallSquare + button.isBordered = false + button.imagePosition = .imageOnly + + updateImage(for: button) + + button.contentTintColor = .white + + return button + } + + func updateNSView(_ nsView: NSPopUpButton, context: Context) { + nsView.isEnabled = !isDisabled + nsView.contentTintColor = isDisabled ? NSColor.white.withAlphaComponent(0.5) : .white + + updateImage(for: nsView) + + context.coordinator.updateMenu(for: nsView, with: menuItems) + } + + private func updateImage(for button: NSPopUpButton) { + let config = NSImage.SymbolConfiguration(textStyle: .body) + let image = NSImage(systemSymbolName: "chevron.down", accessibilityDescription: "More options")? + .withSymbolConfiguration(config) + button.image = image + } + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + class Coordinator: NSObject { + private var menuItemActions: [UUID: () -> Void] = [:] + + func updateMenu(for button: NSPopUpButton, with items: [SplitButtonMenuItem]) { + button.menu = SplitButtonMenuBuilder.buildMenu( + items: items, + pullsDownCoverItem: true, + target: self, + action: #selector(handleMenuItemAction(_:)), + menuItemActions: &menuItemActions + ) + } + + @objc func handleMenuItemAction(_ sender: NSMenuItem) { + if let itemId = sender.representedObject as? UUID, + let action = menuItemActions[itemId] { + action() + } + } + } +} + +@available(macOS 13.0, *) +struct SplitButtonRepresentable: NSViewRepresentable { + let title: String + let primaryAction: () -> Void + let isDisabled: Bool + let menuItems: [SplitButtonMenuItem] + + init( + title: String, + isDisabled: Bool = false, + primaryAction: @escaping () -> Void, + menuItems: [SplitButtonMenuItem] = [] + ) { + self.title = title + self.isDisabled = isDisabled + self.primaryAction = primaryAction + self.menuItems = menuItems + } + + func makeNSView(context: Context) -> NSComboButton { + let button = NSComboButton() + + button.title = title + button.target = context.coordinator + button.action = #selector(Coordinator.handlePrimaryAction) + button.isEnabled = !isDisabled + + + context.coordinator.button = button + context.coordinator.updateMenu(with: menuItems) + + return button + } + + func updateNSView(_ nsView: NSComboButton, context: Context) { + nsView.title = title + nsView.isEnabled = !isDisabled + context.coordinator.updateMenu(with: menuItems) + } + + func makeCoordinator() -> Coordinator { + Coordinator(primaryAction: primaryAction) + } + + class Coordinator: NSObject { + let primaryAction: () -> Void + weak var button: NSComboButton? + private var menuItemActions: [UUID: () -> Void] = [:] + + init(primaryAction: @escaping () -> Void) { + self.primaryAction = primaryAction + } + + @objc func handlePrimaryAction() { + primaryAction() + } + + @objc func handleMenuItemAction(_ sender: NSMenuItem) { + if let itemId = sender.representedObject as? UUID, + let action = menuItemActions[itemId] { + action() + } + } + + func updateMenu(with items: [SplitButtonMenuItem]) { + button?.menu = SplitButtonMenuBuilder.buildMenu( + items: items, + pullsDownCoverItem: false, + target: self, + action: #selector(handleMenuItemAction(_:)), + menuItemActions: &menuItemActions + ) + } + } +} diff --git a/Tool/Sources/Workspace/Workspace.swift b/Tool/Sources/Workspace/Workspace.swift index 149947d6..a179bc83 100644 --- a/Tool/Sources/Workspace/Workspace.swift +++ b/Tool/Sources/Workspace/Workspace.swift @@ -235,6 +235,8 @@ public final class Workspace { } extension Workspace { + static let maxCalculationLength = 200_000 + /// Calculates incremental changes between two document states. /// Each change is computed on the state resulting from the previous change, /// as required by the LSP specification. @@ -285,9 +287,8 @@ extension Workspace { // Find common prefix let oldUTF16 = Array(oldContent.utf16) let newUTF16 = Array(newContent.utf16) - let maxCalculationLength = 10000 - guard oldUTF16.count <= maxCalculationLength, - newUTF16.count <= maxCalculationLength else { + guard oldUTF16.count <= Self.maxCalculationLength, + newUTF16.count <= Self.maxCalculationLength else { // Fallback to full replacement for very large contents return nil } diff --git a/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift index c1d1b415..1b4fbdf3 100644 --- a/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift +++ b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift @@ -447,7 +447,7 @@ public extension AXUIElement { if element.identifier == "editor context" { return .skipDescendantsAndSiblings } - if element.isSourceEditor { + if element.isNonNavigatorSourceEditor { return .skipDescendantsAndSiblings } if description == "Code Coverage Ribbon" { diff --git a/Tool/Sources/XcodeInspector/XcodeInspector.swift b/Tool/Sources/XcodeInspector/XcodeInspector.swift index bb5c3cf9..6a8c5d5e 100644 --- a/Tool/Sources/XcodeInspector/XcodeInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeInspector.swift @@ -292,13 +292,13 @@ public final class XcodeInspector: ObservableObject { focusedElement = xcode.getFocusedElement(shouldRecordStatus: true) - if let editorElement = focusedElement, editorElement.isSourceEditor { + if let editorElement = focusedElement, editorElement.isNonNavigatorSourceEditor { focusedEditor = .init( runningApplication: xcode.runningApplication, element: editorElement ) } else if let element = focusedElement, - let editorElement = element.firstParent(where: \.isSourceEditor) + let editorElement = element.firstParent(where: \.isNonNavigatorSourceEditor) { focusedEditor = .init( runningApplication: xcode.runningApplication, @@ -374,7 +374,7 @@ public final class XcodeInspector: ObservableObject { guard Date().timeIntervalSince(lastRecoveryFromAccessibilityMalfunctioningTimeStamp) > 5 else { return } - if let editor = focusedEditor, !editor.element.isSourceEditor { + if let editor = focusedEditor, !editor.element.isNonNavigatorSourceEditor { NotificationCenter.default.post( name: .accessibilityAPIMalfunctioning, object: "Source Editor Element Corrupted: \(source)" diff --git a/Tool/Tests/WorkspaceTests/WorkspaceTests.swift b/Tool/Tests/WorkspaceTests/WorkspaceTests.swift index 9dd8854b..d6d2e5ec 100644 --- a/Tool/Tests/WorkspaceTests/WorkspaceTests.swift +++ b/Tool/Tests/WorkspaceTests/WorkspaceTests.swift @@ -185,12 +185,12 @@ class WorkspaceTests: XCTestCase { func testCalculateIncrementalChanges_VeryLargeContent() { let workspace = Workspace(workspaceURL: URL(fileURLWithPath: "/test")) - let oldContent = String(repeating: "a", count: 20000) - let newContent = String(repeating: "b", count: 20000) + let oldContent = String(repeating: "a", count: 220_000) + let newContent = String(repeating: "b", count: 220_000) let changes = workspace.calculateIncrementalChanges(oldContent: oldContent, newContent: newContent) - // Should fallback to nil for very large contents (> 10000 characters) + // Should fallback to nil for very large contents (> 200_000 characters) XCTAssertNil(changes, "Very large content should return nil for fallback") }