diff --git a/AppIcon.png b/AppIcon.png index 160db273..1f70976c 100644 Binary files a/AppIcon.png and b/AppIcon.png differ diff --git a/ChatPlugins/Sources/TerminalChatPlugin/TerminalChatPlugin.swift b/ChatPlugins/Sources/TerminalChatPlugin/TerminalChatPlugin.swift index cf804ed5..1360b16d 100644 --- a/ChatPlugins/Sources/TerminalChatPlugin/TerminalChatPlugin.swift +++ b/ChatPlugins/Sources/TerminalChatPlugin/TerminalChatPlugin.swift @@ -5,10 +5,10 @@ import XcodeInspector public final class TerminalChatPlugin: ChatPlugin { public static var id: String { "com.intii.terminal" } - public static var command: String { "run" } - public static var name: String { "Terminal" } + public static var command: String { "shell" } + public static var name: String { "Shell" } public static var description: String { """ - Run the command in the message from terminal. + Run the command in the message from shell. You can use environment variable `$FILE_PATH` and `$PROJECT_ROOT` to access the current file path and project root. """ } diff --git a/Copilot for Xcode.xcodeproj/project.pbxproj b/Copilot for Xcode.xcodeproj/project.pbxproj index a2e4ba8a..056e5761 100644 --- a/Copilot for Xcode.xcodeproj/project.pbxproj +++ b/Copilot for Xcode.xcodeproj/project.pbxproj @@ -235,6 +235,7 @@ C87B03AA293B262E00C77EAE /* PreviousSuggestionCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviousSuggestionCommand.swift; sourceTree = ""; }; C887BC832965D96000931567 /* DEVELOPMENT.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = DEVELOPMENT.md; sourceTree = ""; }; C89E75C22A46FB32000DD64F /* AppDelegate+Menu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+Menu.swift"; sourceTree = ""; }; + C8BE64922EB9B42E00EDB2D7 /* OverlayWindow */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = OverlayWindow; sourceTree = ""; }; C8CD828229B88006008D044D /* TestPlan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = TestPlan.xctestplan; sourceTree = ""; }; C8DCEFFF29CE11D500FDDDD7 /* OpenChat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenChat.swift; sourceTree = ""; }; C8DD9CB02BC673F80036641C /* CloseIdleTabsCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseIdleTabsCommand.swift; sourceTree = ""; }; @@ -342,6 +343,7 @@ C81458AE293A009800135263 /* Config.debug.xcconfig */, C8CD828229B88006008D044D /* TestPlan.xctestplan */, C828B27D2B1F241500E7612A /* ExtensionPoint.appextensionpoint */, + C8BE64922EB9B42E00EDB2D7 /* OverlayWindow */, C84FD9D72CC671C600BE5093 /* ChatPlugins */, C81D181E2A1B509B006C1B70 /* Tool */, C8189B282938979000C9DCDA /* Core */, @@ -784,7 +786,7 @@ "@executable_path/../Frameworks", "@executable_path/../../../../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = "$(APP_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).EditorExtension"; PRODUCT_NAME = Copilot; @@ -812,7 +814,7 @@ "@executable_path/../Frameworks", "@executable_path/../../../../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = "$(APP_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).EditorExtension"; PRODUCT_NAME = Copilot; @@ -965,7 +967,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = "$(APP_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE)"; PRODUCT_MODULE_NAME = Copilot_for_Xcode; @@ -999,7 +1001,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = "$(APP_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE)"; PRODUCT_NAME = "$(HOST_APP_NAME)"; @@ -1015,7 +1017,7 @@ DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5YKZ4Y3DAW; ENABLE_HARDENED_RUNTIME = YES; - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_VERSION = 5.0; @@ -1029,7 +1031,7 @@ DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5YKZ4Y3DAW; ENABLE_HARDENED_RUNTIME = YES; - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_VERSION = 5.0; @@ -1059,7 +1061,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = "$(APP_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).ExtensionService"; PRODUCT_NAME = "$(EXTENSION_SERVICE_NAME)"; @@ -1092,7 +1094,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = "$(APP_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).ExtensionService"; PRODUCT_NAME = "$(EXTENSION_SERVICE_NAME)"; @@ -1112,7 +1114,7 @@ ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).CommunicationBridge"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -1131,7 +1133,7 @@ ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).CommunicationBridge"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon.png deleted file mode 100644 index 291eaac7..00000000 Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon.png and /dev/null differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@128w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@128w.png deleted file mode 100644 index 160db273..00000000 Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@128w.png and /dev/null differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@16w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@16w.png deleted file mode 100644 index 4fcd6278..00000000 Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@16w.png and /dev/null differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w 1.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w 1.png deleted file mode 100644 index e31a8d3b..00000000 Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w 1.png and /dev/null differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w.png deleted file mode 100644 index e31a8d3b..00000000 Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w.png and /dev/null differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w 1.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w 1.png deleted file mode 100644 index ec264755..00000000 Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w 1.png and /dev/null differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w.png deleted file mode 100644 index ec264755..00000000 Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w.png and /dev/null differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w 1.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w 1.png deleted file mode 100644 index 4b760bc1..00000000 Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w 1.png and /dev/null differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w.png deleted file mode 100644 index 4b760bc1..00000000 Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w.png and /dev/null differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@64w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@64w.png deleted file mode 100644 index 8d777985..00000000 Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@64w.png and /dev/null differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/Contents.json b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/Contents.json index 56acb569..457c1fbf 100644 --- a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,61 +1,61 @@ { "images" : [ { - "filename" : "1024 x 1024 your icon@16w.png", + "filename" : "app-icon@16w.png", "idiom" : "mac", "scale" : "1x", "size" : "16x16" }, { - "filename" : "1024 x 1024 your icon@32w 1.png", + "filename" : "app-icon@32w.png", "idiom" : "mac", "scale" : "2x", "size" : "16x16" }, { - "filename" : "1024 x 1024 your icon@32w.png", + "filename" : "app-icon@32w.png", "idiom" : "mac", "scale" : "1x", "size" : "32x32" }, { - "filename" : "1024 x 1024 your icon@64w.png", + "filename" : "app-icon@64w.png", "idiom" : "mac", "scale" : "2x", "size" : "32x32" }, { - "filename" : "1024 x 1024 your icon@128w.png", + "filename" : "app-icon@128w.png", "idiom" : "mac", "scale" : "1x", "size" : "128x128" }, { - "filename" : "1024 x 1024 your icon@256w 1.png", + "filename" : "app-icon@256w.png", "idiom" : "mac", "scale" : "2x", "size" : "128x128" }, { - "filename" : "1024 x 1024 your icon@256w.png", + "filename" : "app-icon@256w.png", "idiom" : "mac", "scale" : "1x", "size" : "256x256" }, { - "filename" : "1024 x 1024 your icon@512w 1.png", + "filename" : "app-icon@512w.png", "idiom" : "mac", "scale" : "2x", "size" : "256x256" }, { - "filename" : "1024 x 1024 your icon@512w.png", + "filename" : "app-icon@512w.png", "idiom" : "mac", "scale" : "1x", "size" : "512x512" }, { - "filename" : "1024 x 1024 your icon.png", + "filename" : "app-icon.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon.png new file mode 100644 index 00000000..f7d77720 Binary files /dev/null and b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon.png differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@128w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@128w.png new file mode 100644 index 00000000..da0bb247 Binary files /dev/null and b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@128w.png differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@16w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@16w.png new file mode 100644 index 00000000..4f3fcc40 Binary files /dev/null and b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@16w.png differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@256w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@256w.png new file mode 100644 index 00000000..1f70976c Binary files /dev/null and b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@256w.png differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@32w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@32w.png new file mode 100644 index 00000000..44400214 Binary files /dev/null and b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@32w.png differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@512w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@512w.png new file mode 100644 index 00000000..78d81e50 Binary files /dev/null and b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@512w.png differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@64w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@64w.png new file mode 100644 index 00000000..a6aae457 Binary files /dev/null and b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@64w.png differ diff --git a/Core/Package.swift b/Core/Package.swift index 3f5d92fb..6cd0910a 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -8,7 +8,7 @@ import PackageDescription let package = Package( name: "Core", - platforms: [.macOS(.v12)], + platforms: [.macOS(.v13)], products: [ .library( name: "Service", @@ -38,6 +38,7 @@ let package = Package( dependencies: [ .package(path: "../Tool"), .package(path: "../ChatPlugins"), + .package(path: "../OverlayWindow"), .package(url: "https://github.com/apple/swift-async-algorithms", from: "1.0.0"), .package(url: "https://github.com/gonzalezreal/swift-markdown-ui", from: "2.4.1"), .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.6.4"), @@ -93,6 +94,7 @@ let package = Package( .product(name: "OpenAIService", package: "Tool"), .product(name: "Preferences", package: "Tool"), .product(name: "CommandHandler", package: "Tool"), + .product(name: "OverlayWindow", package: "OverlayWindow"), .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), .product(name: "Dependencies", package: "swift-dependencies"), diff --git a/Core/Sources/ChatContextCollectors/WebChatContextCollector/QueryWebsiteFunction.swift b/Core/Sources/ChatContextCollectors/WebChatContextCollector/QueryWebsiteFunction.swift index 8a63ba1f..0620123c 100644 --- a/Core/Sources/ChatContextCollectors/WebChatContextCollector/QueryWebsiteFunction.swift +++ b/Core/Sources/ChatContextCollectors/WebChatContextCollector/QueryWebsiteFunction.swift @@ -16,6 +16,10 @@ struct QueryWebsiteFunction: ChatGPTFunction { var botReadableContent: String { return answers.joined(separator: "\n") } + + var userReadableContent: ChatGPTFunctionResultUserReadableContent { + .text(botReadableContent) + } } var name: String { diff --git a/Core/Sources/ChatContextCollectors/WebChatContextCollector/SearchFunction.swift b/Core/Sources/ChatContextCollectors/WebChatContextCollector/SearchFunction.swift index 3b9c1289..60a5504e 100644 --- a/Core/Sources/ChatContextCollectors/WebChatContextCollector/SearchFunction.swift +++ b/Core/Sources/ChatContextCollectors/WebChatContextCollector/SearchFunction.swift @@ -28,6 +28,10 @@ struct SearchFunction: ChatGPTFunction { """ }.joined(separator: "\n") } + + var userReadableContent: ChatGPTFunctionResultUserReadableContent { + .text(botReadableContent) + } } let maxTokens: Int diff --git a/Core/Sources/ChatGPTChatTab/Chat.swift b/Core/Sources/ChatGPTChatTab/Chat.swift index 2923d904..28443876 100644 --- a/Core/Sources/ChatGPTChatTab/Chat.swift +++ b/Core/Sources/ChatGPTChatTab/Chat.swift @@ -1,3 +1,4 @@ +import AppKit import ChatBasic import ChatService import ComposableArchitecture @@ -124,8 +125,6 @@ struct Chat { case sendMessage(UUID) } - @Dependency(\.openURL) var openURL - var body: some ReducerOf { BindingReducer() @@ -214,7 +213,7 @@ struct Chat { print(error) } } else if let url = URL(string: reference.uri), url.scheme != nil { - await openURL(url) + NSWorkspace.shared.open(url) } } diff --git a/Core/Sources/ChatGPTChatTab/Views/Instructions.swift b/Core/Sources/ChatGPTChatTab/Views/Instructions.swift index f15673b3..dba6bfbf 100644 --- a/Core/Sources/ChatGPTChatTab/Views/Instructions.swift +++ b/Core/Sources/ChatGPTChatTab/Views/Instructions.swift @@ -15,7 +15,7 @@ struct Instruction: View { | Plugin Name | Description | | --- | --- | - | `/run` | Runs a command under the project root | + | `/shell` | Runs a command under the project root | | `/shortcut(name)` | Runs a shortcut from the Shortcuts.app, with the previous message as input | To use plugins, you can prefix a message with `/pluginName`. diff --git a/Core/Sources/HostApp/AccountSettings/GitHubCopilotView.swift b/Core/Sources/HostApp/AccountSettings/GitHubCopilotView.swift index d48d1486..ec627113 100644 --- a/Core/Sources/HostApp/AccountSettings/GitHubCopilotView.swift +++ b/Core/Sources/HostApp/AccountSettings/GitHubCopilotView.swift @@ -158,7 +158,7 @@ struct GitHubCopilotView: View { "node" ) ) { - Text("Path to Node (v20.8+)") + Text("Path to Node (v22.0+)") } Text( diff --git a/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift b/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift index 584d9101..033b9850 100644 --- a/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift +++ b/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift @@ -213,7 +213,7 @@ struct CustomCommandView: View { } SubSection(title: Text("Single Round Dialog")) { Text( - "This command allows you to send a message to a temporary chat without opening the chat panel. It is particularly useful for one-time commands, such as running a terminal command with `/run`. For example, you can set the prompt to `/run open .` to open the project in Finder." + "This command allows you to send a message to a temporary chat without opening the chat panel. It is particularly useful for one-time commands, such as running a terminal command with `/shell`. For example, you can set the prompt to `/shell open .` to open the project in Finder." ) } } diff --git a/Core/Sources/HostApp/FeatureSettingsView.swift b/Core/Sources/HostApp/FeatureSettingsView.swift index 03efb50c..e8c1e38f 100644 --- a/Core/Sources/HostApp/FeatureSettingsView.swift +++ b/Core/Sources/HostApp/FeatureSettingsView.swift @@ -43,7 +43,7 @@ struct FeatureSettingsView: View { tag: 3, title: "Xcode", subtitle: "Xcode related features", - image: "app" + image: "hammer.circle" ) ForEach(Array(tabContainer.tabs.enumerated()), id: \.1.id) { index, tab in diff --git a/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift b/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift index ce74605f..9c81038f 100644 --- a/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift +++ b/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift @@ -188,7 +188,9 @@ final class TabToAcceptSuggestion { } guard let presentingSuggestion = filespace.presentingSuggestion else { - Logger.service.info("TabToAcceptSuggestion: No Suggestions found") + Logger.service.info( + "TabToAcceptSuggestion: No presenting found for \(filespace.fileURL.lastPathComponent), found \(filespace.suggestions.count) suggestion, index \(filespace.suggestionIndex)." + ) return .unchanged } diff --git a/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift b/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift index 3dabb27a..25db646f 100644 --- a/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift +++ b/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift @@ -26,8 +26,8 @@ public final class SimpleModificationAgent: ModificationAgent { generateDescriptionRequirement: false ) - for try await (code, description) in stream { - continuation.yield(.code(code)) + for try await response in stream { + continuation.yield(response) } continuation.finish() @@ -51,7 +51,7 @@ public final class SimpleModificationAgent: ModificationAgent { isDetached: Bool, extraSystemPrompt: String?, generateDescriptionRequirement: Bool? - ) async throws -> AsyncThrowingStream<(code: String, description: String), Error> { + ) async throws -> AsyncThrowingStream { let userPreferredLanguage = UserDefaults.shared.value(for: \.chatGPTLanguage) let textLanguage = { if !UserDefaults.shared @@ -226,32 +226,38 @@ public final class SimpleModificationAgent: ModificationAgent { history.append(.init(role: .user, content: requirement)) } } - let stream = chatGPTService.send(memory).compactMap { response in - switch response { - case let .partialText(token): return token - default: return nil - } - }.eraseToThrowingStream() - + let stream = chatGPTService.send(memory) + return .init { continuation in - Task { - var content = "" - var extracted = extractCodeAndDescription(from: content) + let task = Task { + let parser = ExplanationThenCodeStreamParser() do { - for try await fragment in stream { - content.append(fragment) - extracted = extractCodeAndDescription(from: content) - if !content.isEmpty, extracted.code.isEmpty { - continuation.yield((code: content, description: "")) - } else { - continuation.yield(extracted) + func yield(fragments: [ExplanationThenCodeStreamParser.Fragment]) { + for fragment in fragments { + switch fragment { + case let .code(code): + continuation.yield(.code(code)) + case let .explanation(explanation): + continuation.yield(.explanation(explanation)) + } } } + + for try await response in stream { + guard case let .partialText(fragment) = response else { continue } + try Task.checkCancellation() + await yield(fragments: parser.yield(fragment)) + } + await yield(fragments: parser.finish()) continuation.finish() } catch { continuation.finish(throwing: error) } } + + continuation.onTermination = { _ in + task.cancel() + } } } } diff --git a/Core/Sources/Service/RealtimeSuggestionController.swift b/Core/Sources/Service/RealtimeSuggestionController.swift index 09661c0d..39770260 100644 --- a/Core/Sources/Service/RealtimeSuggestionController.swift +++ b/Core/Sources/Service/RealtimeSuggestionController.swift @@ -113,6 +113,7 @@ public actor RealtimeSuggestionController { Task { @WorkspaceActor in // Get cache ready for real-time suggestions. guard UserDefaults.shared.value(for: \.preCacheOnFileOpen) else { return } + guard await XcodeInspector.shared.activeApplication?.isXcode ?? false else { return } guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL else { return } let (_, filespace) = try await Service.shared.workspacePool diff --git a/Core/Sources/Service/Service.swift b/Core/Sources/Service/Service.swift index 1f5da7fc..b1702924 100644 --- a/Core/Sources/Service/Service.swift +++ b/Core/Sources/Service/Service.swift @@ -7,6 +7,7 @@ import Foundation import GitHubCopilotService import KeyBindingManager import Logger +import OverlayWindow import SuggestionService import Toast import Workspace @@ -37,6 +38,7 @@ public final class Service { let globalShortcutManager: GlobalShortcutManager let keyBindingManager: KeyBindingManager let xcodeThemeController: XcodeThemeController = .init() + let overlayWindowController: OverlayWindowController #if canImport(ProService) let proService: ProService @@ -54,6 +56,7 @@ public final class Service { realtimeSuggestionController = .init() scheduledCleaner = .init() + overlayWindowController = .init() #if canImport(ProService) proService = ProService() @@ -94,6 +97,7 @@ public final class Service { #if canImport(ProService) proService.start() #endif + overlayWindowController.start() DependencyUpdater().update() globalShortcutManager.start() keyBindingManager.start() diff --git a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift index d7f83696..c1d38d78 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift @@ -486,8 +486,7 @@ struct PseudoCommandHandler: CommandHandler { #endif } else { Task { - @Dependency(\.openURL) var openURL - await openURL(url) + NSWorkspace.shared.open(url) } } case let .builtinExtension(extensionIdentifier, id, _): @@ -688,7 +687,7 @@ extension PseudoCommandHandler { guard let filespace = await getFilespace(), let sourceEditor = await { if let sourceEditor { sourceEditor } - else { await XcodeInspector.shared.focusedEditor } + else { await XcodeInspector.shared.latestFocusedEditor } }() else { return nil } if Task.isCancelled { return nil } diff --git a/Core/Sources/SuggestionWidget/ChatPanelWindow.swift b/Core/Sources/SuggestionWidget/ChatPanelWindow.swift index cf9a4690..022b424c 100644 --- a/Core/Sources/SuggestionWidget/ChatPanelWindow.swift +++ b/Core/Sources/SuggestionWidget/ChatPanelWindow.swift @@ -51,10 +51,7 @@ final class ChatPanelWindow: WidgetWindow { }()) titlebarAppearsTransparent = true isReleasedWhenClosed = false - isOpaque = false - backgroundColor = .clear level = widgetLevel(1) - hasShadow = true contentView = NSHostingView( rootView: ChatWindowView( diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift index 2144100c..58c6f4d7 100644 --- a/Core/Sources/SuggestionWidget/ChatWindowView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift @@ -4,6 +4,7 @@ import ChatGPTChatTab import ChatTab import ComposableArchitecture import SwiftUI +import SharedUIComponents private let r: Double = 8 @@ -21,13 +22,14 @@ struct ChatWindowView: View { ChatTabBar(store: store) .frame(height: 26) + .clipped() Divider() ChatTabContainer(store: store) .frame(maxWidth: .infinity, maxHeight: .infinity) } - .xcodeStyleFrame(cornerRadius: 10) + .xcodeStyleFrame() .ignoresSafeArea(edges: .top) .onChange(of: store.isPanelDisplayed) { isDisplayed in toggleVisibility(isDisplayed) diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift index 48fac7f4..cb68435f 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift @@ -173,33 +173,48 @@ public struct PromptToCodePanel { range: snippet.attachedRange, references: context.references, topics: context.topics - )).timedDebounce(for: 0.4) + )).map { + switch $0 { + case let .code(code): + return (code: code, description: "") + case let .explanation(explanation): + return (code: "", description: explanation) + } + }.timedDebounce(for: 0.4) { lhs, rhs in + ( + code: lhs.code + rhs.code, + description: lhs.description + rhs.description + ) + } do { for try await response in stream { try Task.checkCancellation() - - switch response { - case let .code(code): - await send(.snippetPanel(.element( - id: snippet.id, - action: .modifyCodeChunkReceived( - code: code, - description: "" - ) - ))) - } + await send(.snippetPanel(.element( + id: snippet.id, + action: .modifyCodeChunkReceived( + code: response.code, + description: response.description + ) + ))) } + await send(.snippetPanel(.element( + id: snippet.id, + action: .modifyCodeFinished + ))) } catch is CancellationError { + await send(.snippetPanel(.element( + id: snippet.id, + action: .modifyCodeFinished + ))) throw CancellationError() } catch { - try Task.checkCancellation() if (error as NSError).code == NSURLErrorCancelled { await send(.snippetPanel(.element( id: snippet.id, - action: .modifyCodeFailed(error: "Cancelled") + action: .modifyCodeFinished ))) - return + throw CancellationError() } await send(.snippetPanel(.element( id: snippet.id, @@ -316,8 +331,8 @@ public struct PromptToCodeSnippetPanel { return .none case let .modifyCodeChunkReceived(code, description): - state.snippet.modifiedCode = code - state.snippet.description = description + state.snippet.modifiedCode += code + state.snippet.description += description return .none case let .modifyCodeFailed(error): diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanelView.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanelView.swift index 3c0d8da0..e876728f 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanelView.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanelView.swift @@ -277,8 +277,13 @@ struct CodeBlockSuggestionPanelView: View { } .xcodeStyleFrame(cornerRadius: { switch suggestionPresentationMode { - case .nearbyTextCursor: 6 - case .floatingWidget: nil + case .nearbyTextCursor: + if #available(macOS 26.0, *) { + return 8 + } else { + return 6 + } + case .floatingWidget: return nil } }()) } diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift index d048f360..ef3b560c 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift @@ -288,12 +288,8 @@ extension PromptToCodePanelView { let isResponding = store.promptToCodeState.isGenerating let isCodeEmpty = store.promptToCodeState.snippets .allSatisfy(\.modifiedCode.isEmpty) - let isDescriptionEmpty = store.promptToCodeState.snippets - .allSatisfy(\.description.isEmpty) var isRespondingButCodeIsReady: Bool { - isResponding - && !isCodeEmpty - && !isDescriptionEmpty + isResponding && !isCodeEmpty } if !isResponding || isRespondingButCodeIsReady { HStack { @@ -619,20 +615,23 @@ extension PromptToCodePanelView { var body: some View { WithPerceptionTracking { - VStack(spacing: 0) { + VStack(alignment: .leading, spacing: 0) { SnippetTitleBar( store: store, language: language, codeForegroundColor: codeForegroundColor, isAttached: isAttached ) + + DescriptionContent(store: store, codeForegroundColor: codeForegroundColor) + CodeContent( store: store, language: language, isGenerating: isGenerating, codeForegroundColor: codeForegroundColor ) - DescriptionContent(store: store, codeForegroundColor: codeForegroundColor) + ErrorMessage(store: store) } } @@ -1146,7 +1145,12 @@ extension PromptToCodePanelView { ChatMessage.Reference( title: "Foo", content: "struct Foo { var foo: Int }", - kind: .symbol(.struct, uri: "file:///path/to/file.txt", startLine: 13, endLine: 13) + kind: .symbol( + .struct, + uri: "file:///path/to/file.txt", + startLine: 13, + endLine: 13 + ) ), ], )), diff --git a/Core/Sources/SuggestionWidget/WidgetView.swift b/Core/Sources/SuggestionWidget/WidgetView.swift index 7ae716f6..f07816bf 100644 --- a/Core/Sources/SuggestionWidget/WidgetView.swift +++ b/Core/Sources/SuggestionWidget/WidgetView.swift @@ -42,7 +42,7 @@ struct WidgetView: View { value: isHovering ) .animation( - .easeInOut(duration: 0.2), + .easeInOut(duration: 0.4), value: store.isProcessing ) } @@ -54,11 +54,9 @@ struct WidgetAnimatedCapsule: View { let store: StoreOf var isHovering: Bool - @State private var animatedProgress: CGFloat = 0 // 0~1 + @State private var breathingOpacity: CGFloat = 1.0 @State private var animationTask: Task? - private let movingSegmentLength: CGFloat = 0.28 - var body: some View { GeometryReader { geo in WithPerceptionTracking { @@ -68,6 +66,7 @@ struct WidgetAnimatedCapsule: View { let backgroundWidth = capsuleWidth let foregroundWidth = max(capsuleWidth - 4, 2) let padding = (backgroundWidth - foregroundWidth) / 2 + let foregroundHeight = capsuleHeight - padding * 2 ZStack { Capsule() @@ -91,87 +90,65 @@ struct WidgetAnimatedCapsule: View { } } .frame(width: backgroundWidth, height: capsuleHeight) - .animation(.easeInOut(duration: 0.14), value: isHovering) Capsule() - .fill(Color.accentColor.opacity(0.8)) + .fill(Color.white) .frame( width: foregroundWidth, - height: capsuleHeight * movingSegmentLength - ) - .opacity(store.isProcessing ? 1 : 0) - .position( - x: capsuleWidth / 2, - y: { - let height = capsuleHeight - padding * 2 - let base = padding - return base + height * (normalizedStart() + movingSegmentLength / 2) - }() + height: foregroundHeight ) - .animation(nil, value: store.isProcessing) - .animation(.easeInOut(duration: 0.14), value: isHovering) + .opacity({ + let base = store.isProcessing ? breathingOpacity : 0 + if isHovering { + return min(base + 0.5, 1.0) + } + return base + }()) + .blur(radius: 2) } .onAppear { - updateAnimationTask(isProcessing: store.isProcessing) + updateBreathingAnimation(isProcessing: store.isProcessing) } .onChange(of: store.isProcessing) { newValue in - updateAnimationTask(isProcessing: newValue) - } - .onChange(of: store.isContentEmpty) { _ in - if !store.isProcessing { - animatedProgress = store.isContentEmpty ? 0 : 1 - } + updateBreathingAnimation(isProcessing: newValue) } - .onChange(of: isHovering) { _ in } } } } - // 进度条起点 - private func normalizedStart() -> CGFloat { - let p = max(0, min(1, animatedProgress)) - return p * (1 - movingSegmentLength) - } - - // 动画任务 - private func updateAnimationTask(isProcessing: Bool) { + private func updateBreathingAnimation(isProcessing: Bool) { animationTask?.cancel() animationTask = nil if isProcessing { - animationTask = Task { [weak store] in - await MainActor.run { - animatedProgress = 0 - } + animationTask = Task { while !Task.isCancelled { await MainActor.run { - withAnimation(.linear(duration: 1.2)) { - animatedProgress = 1 + withAnimation(.easeInOut(duration: 1.2)) { + breathingOpacity = 0.3 } } try? await Task.sleep(nanoseconds: UInt64(1.2 * 1_000_000_000)) if Task.isCancelled { break } - if !(store?.isProcessing ?? true) { break } + if !(store.isProcessing) { break } await MainActor.run { - withAnimation(.linear(duration: 1.2)) { - animatedProgress = 0 + withAnimation(.easeInOut(duration: 1.2)) { + breathingOpacity = 1.0 } } try? await Task.sleep(nanoseconds: UInt64(1.2 * 1_000_000_000)) if Task.isCancelled { break } - if !(store?.isProcessing ?? true) { break } + if !(store.isProcessing) { break } } } } else { withAnimation(.easeInOut(duration: 0.2)) { - animatedProgress = store.isContentEmpty ? 0 : 1 + breathingOpacity = 0 } } } } -// 下面的WidgetContextMenu和其它内容保持不变喵~ - struct WidgetContextMenu: View { @AppStorage(\.useGlobalChat) var useGlobalChat @AppStorage(\.realtimeSuggestionToggle) var realtimeSuggestionToggle @@ -195,7 +172,7 @@ struct WidgetContextMenu: View { Button(action: { store.send(.openModificationButtonClicked) }) { - Text("Write or Modify Code") + Text("Write or Edit Code") } customCommandMenu() diff --git a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift index d53d7867..2f70e0e3 100644 --- a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift +++ b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift @@ -281,9 +281,13 @@ extension WidgetWindowsController { let focusElement = await xcodeInspector.focusedEditor?.element, let parent = focusElement.parent, let frame = parent.rect, - let screen = NSScreen.screens.first(where: { $0.frame.origin == .zero }), - let windowContainingScreen = NSScreen.screens - .first(where: { $0.frame.contains(frame.origin) }) + let screen = NSScreen.screens.first( + where: { $0.frame.origin == .zero } + ) ?? NSScreen.main, + let windowContainingScreen = NSScreen.screens.first(where: { + let flippedScreenFrame = $0.frame.flipped(relativeTo: screen.frame) + return flippedScreenFrame.contains(frame.origin) + }) { let positionMode = UserDefaults.shared .value(for: \.suggestionWidgetPositionMode) @@ -358,7 +362,7 @@ extension WidgetWindowsController { defaultPanelLocation: .init(frame: .zero, alignPanelTop: false) ) } - + window = workspaceWindow frame = rect } @@ -700,7 +704,6 @@ public final class WidgetWindows { defer: false ) it.isReleasedWhenClosed = false - it.isOpaque = false it.backgroundColor = .clear it.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient] it.hasShadow = false @@ -718,10 +721,9 @@ public final class WidgetWindows { defer: false ) it.isReleasedWhenClosed = false - it.isOpaque = false it.backgroundColor = .clear it.level = widgetLevel(0) - it.hasShadow = true + it.hasShadow = false it.contentView = NSHostingView( rootView: WidgetView( store: store.scope( @@ -744,11 +746,10 @@ public final class WidgetWindows { defer: false ) it.isReleasedWhenClosed = false - it.isOpaque = false it.backgroundColor = .clear it.level = widgetLevel(2) it.hoveringLevel = widgetLevel(2) - it.hasShadow = true + it.hasShadow = false it.contentView = NSHostingView( rootView: SharedPanelView( store: store.scope( @@ -778,10 +779,11 @@ public final class WidgetWindows { defer: false ) it.isReleasedWhenClosed = false - it.isOpaque = false it.backgroundColor = .clear it.level = widgetLevel(2) - it.hasShadow = true + it.hasShadow = false + it.menu = nil + it.animationBehavior = .utilityWindow it.contentView = NSHostingView( rootView: SuggestionPanelView( store: store.scope( @@ -876,6 +878,8 @@ class WidgetWindow: CanBecomeKeyWindow { } var hoveringLevel: NSWindow.Level = widgetLevel(0) + + override var isFloatingPanel: Bool { true } var defaultCollectionBehavior: NSWindow.CollectionBehavior { [.fullScreenAuxiliary, .transient] @@ -930,3 +934,21 @@ func widgetLevel(_ addition: Int) -> NSWindow.Level { return .init(minimumWidgetLevel + addition) } +extension CGRect { + func flipped(relativeTo reference: CGRect) -> CGRect { + let flippedOrigin = CGPoint( + x: origin.x, + y: reference.height - origin.y - height + ) + return CGRect(origin: flippedOrigin, size: size) + } + + func relative(to reference: CGRect) -> CGRect { + let relativeOrigin = CGPoint( + x: origin.x - reference.origin.x, + y: origin.y - reference.origin.y + ) + return CGRect(origin: relativeOrigin, size: size) + } +} + diff --git a/EditorExtension/PromptToCodeCommand.swift b/EditorExtension/PromptToCodeCommand.swift index e7086d57..a2f814ac 100644 --- a/EditorExtension/PromptToCodeCommand.swift +++ b/EditorExtension/PromptToCodeCommand.swift @@ -4,7 +4,7 @@ import Foundation import XcodeKit class PromptToCodeCommand: NSObject, XCSourceEditorCommand, CommandType { - var name: String { "Write or Modify Code" } + var name: String { "Write or Edit Code" } func perform( with invocation: XCSourceEditorCommandInvocation, diff --git a/ExtensionService/AppDelegate+Menu.swift b/ExtensionService/AppDelegate+Menu.swift index 5bec033c..9107c97a 100644 --- a/ExtensionService/AppDelegate+Menu.swift +++ b/ExtensionService/AppDelegate+Menu.swift @@ -169,7 +169,7 @@ extension AppDelegate: NSMenuDelegate { menu.items.append(.text("Focused Element: N/A")) } - if let sourceEditor = inspector.focusedEditor { + if let sourceEditor = inspector.latestFocusedEditor { let label = sourceEditor.element.description menu.items .append(.text("Active Source Editor: \(label.isEmpty ? "Unknown" : label)")) @@ -226,6 +226,15 @@ extension AppDelegate: NSMenuDelegate { action: #selector(restartXcodeInspector), keyEquivalent: "" )) + + let isDebuggingOverlay = UserDefaults.shared.value(for: \.debugOverlayPanel) + let debugOverlayItem = NSMenuItem( + title: "Debug Window Overlays", + action: #selector(toggleDebugOverlayPanel), + keyEquivalent: "" + ) + debugOverlayItem.state = isDebuggingOverlay ? .on : .off + menu.items.append(debugOverlayItem) default: break @@ -266,6 +275,11 @@ private extension AppDelegate { await workspacePool.destroy() } } + + @objc func toggleDebugOverlayPanel() { + let isDebuggingOverlay = UserDefaults.shared.value(for: \.debugOverlayPanel) + UserDefaults.shared.set(!isDebuggingOverlay, for: \.debugOverlayPanel) + } } private extension NSMenuItem { diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon.png deleted file mode 100644 index 291eaac7..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@128w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@128w.png deleted file mode 100644 index 160db273..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@128w.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@16w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@16w.png deleted file mode 100644 index 4fcd6278..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@16w.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w 1.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w 1.png deleted file mode 100644 index e31a8d3b..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w 1.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w.png deleted file mode 100644 index e31a8d3b..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w 1.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w 1.png deleted file mode 100644 index ec264755..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w 1.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w.png deleted file mode 100644 index ec264755..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w 1.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w 1.png deleted file mode 100644 index 4b760bc1..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w 1.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w.png deleted file mode 100644 index 4b760bc1..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@64w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@64w.png deleted file mode 100644 index 8d777985..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@64w.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/Contents.json b/ExtensionService/Assets.xcassets/AppIcon.appiconset/Contents.json index 56acb569..d3a89dc6 100644 --- a/ExtensionService/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/ExtensionService/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,61 +1,61 @@ { "images" : [ { - "filename" : "1024 x 1024 your icon@16w.png", + "filename" : "service-icon@16w.png", "idiom" : "mac", "scale" : "1x", "size" : "16x16" }, { - "filename" : "1024 x 1024 your icon@32w 1.png", + "filename" : "service-icon@32w.png", "idiom" : "mac", "scale" : "2x", "size" : "16x16" }, { - "filename" : "1024 x 1024 your icon@32w.png", + "filename" : "service-icon@32w.png", "idiom" : "mac", "scale" : "1x", "size" : "32x32" }, { - "filename" : "1024 x 1024 your icon@64w.png", + "filename" : "service-icon@64w.png", "idiom" : "mac", "scale" : "2x", "size" : "32x32" }, { - "filename" : "1024 x 1024 your icon@128w.png", + "filename" : "service-icon@128w.png", "idiom" : "mac", "scale" : "1x", "size" : "128x128" }, { - "filename" : "1024 x 1024 your icon@256w 1.png", + "filename" : "service-icon@256w.png", "idiom" : "mac", "scale" : "2x", "size" : "128x128" }, { - "filename" : "1024 x 1024 your icon@256w.png", + "filename" : "service-icon@256w.png", "idiom" : "mac", "scale" : "1x", "size" : "256x256" }, { - "filename" : "1024 x 1024 your icon@512w 1.png", + "filename" : "service-icon@512w.png", "idiom" : "mac", "scale" : "2x", "size" : "256x256" }, { - "filename" : "1024 x 1024 your icon@512w.png", + "filename" : "service-icon@512w.png", "idiom" : "mac", "scale" : "1x", "size" : "512x512" }, { - "filename" : "1024 x 1024 your icon.png", + "filename" : "service-icon.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon.png new file mode 100644 index 00000000..29782a0f Binary files /dev/null and b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon.png differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@128w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@128w.png new file mode 100644 index 00000000..c9479d72 Binary files /dev/null and b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@128w.png differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@16w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@16w.png new file mode 100644 index 00000000..f00e273e Binary files /dev/null and b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@16w.png differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@256w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@256w.png new file mode 100644 index 00000000..0546b089 Binary files /dev/null and b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@256w.png differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@32w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@32w.png new file mode 100644 index 00000000..9f60ddf8 Binary files /dev/null and b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@32w.png differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@512w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@512w.png new file mode 100644 index 00000000..c00d18cf Binary files /dev/null and b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@512w.png differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@64w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@64w.png new file mode 100644 index 00000000..625b2717 Binary files /dev/null and b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@64w.png differ diff --git a/OverlayWindow/.gitignore b/OverlayWindow/.gitignore new file mode 100644 index 00000000..0023a534 --- /dev/null +++ b/OverlayWindow/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/OverlayWindow/Package.swift b/OverlayWindow/Package.swift new file mode 100644 index 00000000..b875c713 --- /dev/null +++ b/OverlayWindow/Package.swift @@ -0,0 +1,39 @@ +// swift-tools-version: 6.2 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "OverlayWindow", + platforms: [.macOS(.v13)], + products: [ + .library( + name: "OverlayWindow", + targets: ["OverlayWindow"] + ), + ], + dependencies: [ + .package(path: "../Tool"), + .package(url: "https://github.com/pointfreeco/swift-perception", from: "1.3.4"), + .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.4.0"), + ], + targets: [ + .target( + name: "OverlayWindow", + dependencies: [ + .product(name: "AppMonitoring", package: "Tool"), + .product(name: "Toast", package: "Tool"), + .product(name: "Preferences", package: "Tool"), + .product(name: "Logger", package: "Tool"), + .product(name: "DebounceFunction", package: "Tool"), + .product(name: "Perception", package: "swift-perception"), + .product(name: "Dependencies", package: "swift-dependencies"), + ] + ), + .testTarget( + name: "OverlayWindowTests", + dependencies: ["OverlayWindow", .product(name: "DebounceFunction", package: "Tool")] + ), + ] +) + diff --git a/OverlayWindow/Sources/OverlayWindow/IDEWorkspaceWindowOverlayWindowController.swift b/OverlayWindow/Sources/OverlayWindow/IDEWorkspaceWindowOverlayWindowController.swift new file mode 100644 index 00000000..48263e26 --- /dev/null +++ b/OverlayWindow/Sources/OverlayWindow/IDEWorkspaceWindowOverlayWindowController.swift @@ -0,0 +1,147 @@ +import AppKit +import AXExtension +import AXNotificationStream +import DebounceFunction +import Foundation +import Perception +import SwiftUI +import XcodeInspector + +@MainActor +public protocol IDEWorkspaceWindowOverlayWindowControllerContentProvider { + associatedtype Content: View + func createWindow() -> NSWindow? + func createContent() -> Content + func destroy() + + init(windowInspector: WorkspaceXcodeWindowInspector, application: NSRunningApplication) +} + +extension IDEWorkspaceWindowOverlayWindowControllerContentProvider { + var contentBody: AnyView { + AnyView(createContent()) + } +} + +@MainActor +final class IDEWorkspaceWindowOverlayWindowController { + private var lastAccessDate: Date = .init() + let application: NSRunningApplication + let inspector: WorkspaceXcodeWindowInspector + let contentProviders: [any IDEWorkspaceWindowOverlayWindowControllerContentProvider] + let maskPanel: OverlayPanel + var windowElement: AXUIElement + private var axNotificationTask: Task? + let updateFrameThrottler = ThrottleRunner(duration: 0.2) + + init( + inspector: WorkspaceXcodeWindowInspector, + application: NSRunningApplication, + contentProviderFactory: ( + _ windowInspector: WorkspaceXcodeWindowInspector, _ application: NSRunningApplication + ) -> [any IDEWorkspaceWindowOverlayWindowControllerContentProvider] + ) { + self.inspector = inspector + self.application = application + let contentProviders = contentProviderFactory(inspector, application) + self.contentProviders = contentProviders + windowElement = inspector.uiElement + + let panel = OverlayPanel( + contentRect: .init(x: 0, y: 0, width: 200, height: 200) + ) { + ZStack { + ForEach(0..( + contentRect: NSRect, + @ViewBuilder content: @escaping () -> Content + ) { + super.init( + contentRect: contentRect, + styleMask: [ + .borderless, + .nonactivatingPanel, + .fullSizeContentView, + ], + backing: .buffered, + defer: false + ) + + isReleasedWhenClosed = false + menu = nil + isOpaque = true + backgroundColor = .clear + hasShadow = false + alphaValue = 1.0 + collectionBehavior = [.fullScreenAuxiliary] + isFloatingPanel = true + titleVisibility = .hidden + titlebarAppearsTransparent = true + animationBehavior = .utilityWindow + + standardWindowButton(.closeButton)?.isHidden = true + standardWindowButton(.miniaturizeButton)?.isHidden = true + standardWindowButton(.zoomButton)?.isHidden = true + + contentView = NSHostingView( + rootView: ContentWrapper(panelState: panelState) { content() } + ) + } + + override public var canBecomeKey: Bool { + return _canBecomeKey + } + + override public var canBecomeMain: Bool { + return false + } + + override public func setIsVisible(_ visible: Bool) { + _canBecomeKey = false + defer { _canBecomeKey = true } + super.setIsVisible(visible) + } + + public func moveToActiveSpace() { + collectionBehavior = [.fullScreenAuxiliary, .moveToActiveSpace] + Task { @MainActor in + try await Task.sleep(nanoseconds: 50_000_000) + self.collectionBehavior = [.fullScreenAuxiliary] + } + } + + func setTopLeftCoordinateFrame(_ frame: CGRect, display: Bool) { + let zeroScreen = NSScreen.screens.first { $0.frame.origin == .zero } + ?? NSScreen.primaryScreen ?? NSScreen.main + let panelFrame = Self.convertAXRectToNSPanelFrame( + axRect: frame, + forPrimaryScreen: zeroScreen + ) + panelState.windowFrame = frame + panelState.windowFrameNSCoordinate = panelFrame + setFrame(panelFrame, display: display) + } + + static func convertAXRectToNSPanelFrame( + axRect: CGRect, + forPrimaryScreen screen: NSScreen? + ) -> CGRect { + guard let screen = screen else { return .zero } + let screenFrame = screen.frame + let flippedY = screenFrame.origin.y + screenFrame.size + .height - (axRect.origin.y + axRect.size.height) + return CGRect( + x: axRect.origin.x, + y: flippedY, + width: axRect.size.width, + height: axRect.size.height + ) + } + + struct ContentWrapper: View { + let panelState: PanelState + @ViewBuilder let content: () -> Content + @AppStorage(\.debugOverlayPanel) var debugOverlayPanel + + var body: some View { + WithPerceptionTracking { + ZStack { + Rectangle().fill(.green.opacity(debugOverlayPanel ? 0.1 : 0)) + .allowsHitTesting(false) + content() + .environment(\.overlayFrame, panelState.windowFrame) + .environment(\.overlayDebug, debugOverlayPanel) + } + } + } + } +} + +func overlayLevel(_ addition: Int) -> NSWindow.Level { + let minimumWidgetLevel: Int + #if DEBUG + minimumWidgetLevel = NSWindow.Level.floating.rawValue + 1 + #else + minimumWidgetLevel = NSWindow.Level.floating.rawValue + #endif + return .init(minimumWidgetLevel + addition) +} + +public extension CGRect { + func flipped(relativeTo reference: CGRect) -> CGRect { + let flippedOrigin = CGPoint( + x: origin.x, + y: reference.height - origin.y - height + ) + return CGRect(origin: flippedOrigin, size: size) + } + + func relative(to reference: CGRect) -> CGRect { + let relativeOrigin = CGPoint( + x: origin.x - reference.origin.x, + y: origin.y - reference.origin.y + ) + return CGRect(origin: relativeOrigin, size: size) + } +} + +public extension NSScreen { + var isPrimary: Bool { + let id = deviceDescription[.init("NSScreenNumber")] as? CGDirectDisplayID + return id == CGMainDisplayID() + } + + static var primaryScreen: NSScreen? { + NSScreen.screens.first { + let id = $0.deviceDescription[.init("NSScreenNumber")] as? CGDirectDisplayID + return id == CGMainDisplayID() + } + } +} + diff --git a/OverlayWindow/Sources/OverlayWindow/OverlayWindowController.swift b/OverlayWindow/Sources/OverlayWindow/OverlayWindowController.swift new file mode 100644 index 00000000..f4dbaa73 --- /dev/null +++ b/OverlayWindow/Sources/OverlayWindow/OverlayWindowController.swift @@ -0,0 +1,206 @@ +import AppKit +import DebounceFunction +import Foundation +import Perception +import XcodeInspector + +@MainActor +public final class OverlayWindowController { + public typealias IDEWorkspaceWindowOverlayWindowControllerContentProviderFactory = + @MainActor @Sendable ( + _ windowInspector: WorkspaceXcodeWindowInspector, + _ application: NSRunningApplication + ) -> any IDEWorkspaceWindowOverlayWindowControllerContentProvider + + static var ideWindowOverlayWindowControllerContentProviderFactories: + [IDEWorkspaceWindowOverlayWindowControllerContentProviderFactory] = [] + + var ideWindowOverlayWindowControllers = + [ObjectIdentifier: IDEWorkspaceWindowOverlayWindowController]() + var updateWindowStateTask: Task? + + let windowUpdateThrottler = ThrottleRunner(duration: 0.2) + + lazy var fullscreenDetector = { + let it = NSWindow( + contentRect: .zero, + styleMask: .borderless, + backing: .buffered, + defer: false + ) + it.isReleasedWhenClosed = false + it.isOpaque = false + it.backgroundColor = .clear + it.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient] + it.hasShadow = false + it.setIsVisible(false) + return it + }() + + public init() {} + + public func start() { + observeEvents() + _ = fullscreenDetector + } + + public nonisolated static func registerIDEWorkspaceWindowOverlayWindowControllerContentProviderFactory( + _ factory: @escaping IDEWorkspaceWindowOverlayWindowControllerContentProviderFactory + ) { + Task { @MainActor in + ideWindowOverlayWindowControllerContentProviderFactories.append(factory) + } + } +} + +extension OverlayWindowController { + func observeEvents() { + observeWindowChange() + + updateWindowStateTask = Task { [weak self] in + if let self { await handleSpaceChange() } + + await withThrowingTaskGroup(of: Void.self) { [weak self] group in + // active space did change + _ = group.addTaskUnlessCancelled { [weak self] in + let sequence = NSWorkspace.shared.notificationCenter + .notifications(named: NSWorkspace.activeSpaceDidChangeNotification) + for await _ in sequence { + guard let self else { return } + try Task.checkCancellation() + await handleSpaceChange() + } + } + } + } + } +} + +private extension OverlayWindowController { + func observeWindowChange() { + if ideWindowOverlayWindowControllers.isEmpty { + if let app = XcodeInspector.shared.activeXcode, + let windowInspector = XcodeInspector.shared + .focusedWindow as? WorkspaceXcodeWindowInspector + { + createNewIDEOverlayWindowController( + inspector: windowInspector, + application: app.runningApplication + ) + } + } + + withPerceptionTracking { + _ = XcodeInspector.shared.focusedWindow + _ = XcodeInspector.shared.activeXcode + _ = XcodeInspector.shared.activeApplication + } onChange: { [weak self] in + guard let self else { return } + Task { @MainActor in + defer { self.observeWindowChange() } + await self.windowUpdateThrottler.throttle { [weak self] in + await self?.handleOverlayStatusChange() + } + } + } + } + + func createNewIDEOverlayWindowController( + inspector: WorkspaceXcodeWindowInspector, + application: NSRunningApplication + ) { + let id = ObjectIdentifier(inspector) + let newController = IDEWorkspaceWindowOverlayWindowController( + inspector: inspector, + application: application, + contentProviderFactory: { + windowInspector, application in + OverlayWindowController.ideWindowOverlayWindowControllerContentProviderFactories + .map { $0(windowInspector, application) } + } + ) + newController.access() + ideWindowOverlayWindowControllers[id] = newController + } + + func removeIDEOverlayWindowController(for id: ObjectIdentifier) { + if let controller = ideWindowOverlayWindowControllers[id] { + controller.destroy() + } + ideWindowOverlayWindowControllers[id] = nil + } + + func handleSpaceChange() async { + let windowInspector = XcodeInspector.shared.focusedWindow + guard let activeWindowController = { + if let windowInspector = windowInspector as? WorkspaceXcodeWindowInspector { + let id = ObjectIdentifier(windowInspector) + return ideWindowOverlayWindowControllers[id] + } else { + return nil + } + }() else { return } + + let activeXcode = XcodeInspector.shared.activeXcode + let xcode = activeXcode?.appElement + let isXcodeActive = xcode?.isFrontmost ?? false + if isXcodeActive { + activeWindowController.maskPanel.moveToActiveSpace() + } + + if fullscreenDetector.isOnActiveSpace, xcode?.focusedWindow != nil { + activeWindowController.maskPanel.orderFrontRegardless() + } + } + + func handleOverlayStatusChange() { + guard XcodeInspector.shared.activeApplication?.isXcode ?? false else { + var closedControllers: [ObjectIdentifier] = [] + for (id, controller) in ideWindowOverlayWindowControllers { + if controller.isWindowClosed { + controller.dim() + closedControllers.append(id) + } else { + controller.dim() + } + } + for id in closedControllers { + removeIDEOverlayWindowController(for: id) + } + return + } + + guard let app = XcodeInspector.shared.activeXcode else { + for (_, controller) in ideWindowOverlayWindowControllers { + controller.hide() + } + return + } + + let windowInspector = XcodeInspector.shared.focusedWindow + if let ideWindowInspector = windowInspector as? WorkspaceXcodeWindowInspector { + let objectID = ObjectIdentifier(ideWindowInspector) + // Workspace window is active + // Hide all controllers first + for (id, controller) in ideWindowOverlayWindowControllers { + if id != objectID { + controller.hide() + } + } + if let controller = ideWindowOverlayWindowControllers[objectID] { + controller.access() + } else { + createNewIDEOverlayWindowController( + inspector: ideWindowInspector, + application: app.runningApplication + ) + } + } else { + // Not a workspace window, dim all controllers + for (_, controller) in ideWindowOverlayWindowControllers { + controller.dim() + } + } + } +} + diff --git a/OverlayWindow/Tests/OverlayWindowTests/WindowTests.swift b/OverlayWindow/Tests/OverlayWindowTests/WindowTests.swift new file mode 100644 index 00000000..98b0a5bf --- /dev/null +++ b/OverlayWindow/Tests/OverlayWindowTests/WindowTests.swift @@ -0,0 +1,5 @@ +import Testing + +@Test func example() async throws { + // Write your test here and use APIs like `#expect(...)` to check expected conditions. +} diff --git a/README.md b/README.md index 8ded90d5..c4066a45 100644 --- a/README.md +++ b/README.md @@ -269,7 +269,7 @@ You can detach the chat panel by simply dragging it away. Once detached, the cha #### Chat Commands -The chat panel supports chat plugins that may not require an OpenAI API key. For example, if you need to use the `/run` plugin, you just type +The chat panel supports chat plugins that may not require an OpenAI API key. For example, if you need to use the `/shell` plugin, you just type ``` /run echo hello @@ -283,7 +283,7 @@ If you need to end a plugin, you can just type | Command | Description | | :--------------------: | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `/run` | Runs the command under the project root. | +| `/shell` | Runs the command under the project root. | | | Environment variable:
- `PROJECT_ROOT` to get the project root.
- `FILE_PATH` to get the editing file path. | | `/shortcut(name)` | Run a shortcut from the Shortcuts.app, and use the following message as the input. | | | If the message is empty, it will use the previous message as input. The output of the shortcut will be printed as a reply from the bot. | @@ -304,7 +304,7 @@ This feature is recommended when you need to update a specific piece of code. So #### Commands -- Write or Modify Code: Open a modification window, where you can use natural language to write or edit selected code. +- Write or Edit Code: Open a modification window, where you can use natural language to write or edit selected code. - Accept Modification: Accept the result of modification. ### Custom Commands diff --git a/TestPlan.xctestplan b/TestPlan.xctestplan index f015c57e..c9ebe525 100644 --- a/TestPlan.xctestplan +++ b/TestPlan.xctestplan @@ -24,86 +24,86 @@ "testTargets" : [ { "target" : { - "containerPath" : "container:Core", - "identifier" : "ChatServiceTests", - "name" : "ChatServiceTests" + "containerPath" : "container:Tool", + "identifier" : "SharedUIComponentsTests", + "name" : "SharedUIComponentsTests" } }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "OpenAIServiceTests", - "name" : "OpenAIServiceTests" + "identifier" : "ActiveDocumentChatContextCollectorTests", + "name" : "ActiveDocumentChatContextCollectorTests" } }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "SuggestionInjectorTests", - "name" : "SuggestionInjectorTests" + "identifier" : "CodeDiffTests", + "name" : "CodeDiffTests" } }, { "target" : { - "containerPath" : "container:Tool", - "identifier" : "WebSearchServiceTests", - "name" : "WebSearchServiceTests" + "containerPath" : "container:Core", + "identifier" : "ServiceUpdateMigrationTests", + "name" : "ServiceUpdateMigrationTests" } }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "GitHubCopilotServiceTests", - "name" : "GitHubCopilotServiceTests" + "identifier" : "ASTParserTests", + "name" : "ASTParserTests" } }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "JoinJSONTests", - "name" : "JoinJSONTests" + "identifier" : "SuggestionProviderTests", + "name" : "SuggestionProviderTests" } }, { "target" : { - "containerPath" : "container:Tool", - "identifier" : "CodeDiffTests", - "name" : "CodeDiffTests" + "containerPath" : "container:Core", + "identifier" : "PromptToCodeServiceTests", + "name" : "PromptToCodeServiceTests" } }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "SuggestionBasicTests", - "name" : "SuggestionBasicTests" + "identifier" : "KeychainTests", + "name" : "KeychainTests" } }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "FocusedCodeFinderTests", - "name" : "FocusedCodeFinderTests" + "identifier" : "JoinJSONTests", + "name" : "JoinJSONTests" } }, { "target" : { - "containerPath" : "container:Tool", - "identifier" : "ASTParserTests", - "name" : "ASTParserTests" + "containerPath" : "container:Core", + "identifier" : "ServiceTests", + "name" : "ServiceTests" } }, { "target" : { - "containerPath" : "container:Core", - "identifier" : "PromptToCodeServiceTests", - "name" : "PromptToCodeServiceTests" + "containerPath" : "container:OverlayWindow", + "identifier" : "OverlayWindowTests", + "name" : "OverlayWindowTests" } }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "TokenEncoderTests", - "name" : "TokenEncoderTests" + "identifier" : "SuggestionBasicTests", + "name" : "SuggestionBasicTests" } }, { @@ -116,64 +116,71 @@ { "target" : { "containerPath" : "container:Tool", - "identifier" : "SharedUIComponentsTests", - "name" : "SharedUIComponentsTests" + "identifier" : "GitHubCopilotServiceTests", + "name" : "GitHubCopilotServiceTests" + } + }, + { + "target" : { + "containerPath" : "container:Tool", + "identifier" : "OpenAIServiceTests", + "name" : "OpenAIServiceTests" } }, { "target" : { "containerPath" : "container:Core", - "identifier" : "KeyBindingManagerTests", - "name" : "KeyBindingManagerTests" + "identifier" : "SuggestionWidgetTests", + "name" : "SuggestionWidgetTests" } }, { "target" : { - "containerPath" : "container:Tool", - "identifier" : "XcodeInspectorTests", - "name" : "XcodeInspectorTests" + "containerPath" : "container:Core", + "identifier" : "KeyBindingManagerTests", + "name" : "KeyBindingManagerTests" } }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "ActiveDocumentChatContextCollectorTests", - "name" : "ActiveDocumentChatContextCollectorTests" + "identifier" : "FocusedCodeFinderTests", + "name" : "FocusedCodeFinderTests" } }, { "target" : { - "containerPath" : "container:Core", - "identifier" : "ServiceUpdateMigrationTests", - "name" : "ServiceUpdateMigrationTests" + "containerPath" : "container:Tool", + "identifier" : "WebSearchServiceTests", + "name" : "WebSearchServiceTests" } }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "SuggestionProviderTests", - "name" : "SuggestionProviderTests" + "identifier" : "XcodeInspectorTests", + "name" : "XcodeInspectorTests" } }, { "target" : { "containerPath" : "container:Core", - "identifier" : "ServiceTests", - "name" : "ServiceTests" + "identifier" : "ChatServiceTests", + "name" : "ChatServiceTests" } }, { "target" : { - "containerPath" : "container:Core", - "identifier" : "SuggestionWidgetTests", - "name" : "SuggestionWidgetTests" + "containerPath" : "container:Tool", + "identifier" : "SuggestionInjectorTests", + "name" : "SuggestionInjectorTests" } }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "KeychainTests", - "name" : "KeychainTests" + "identifier" : "TokenEncoderTests", + "name" : "TokenEncoderTests" } } ], diff --git a/Tool/Package.swift b/Tool/Package.swift index 0dcdfc6f..f303e44c 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -231,6 +231,7 @@ let package = Package( ), ] ), + .testTarget(name: "ModificationBasicTests", dependencies: ["ModificationBasic"]), .target( name: "PromptToCodeCustomization", diff --git a/Tool/Sources/AXExtension/AXUIElement.swift b/Tool/Sources/AXExtension/AXUIElement.swift index 71c48d06..e54bfaff 100644 --- a/Tool/Sources/AXExtension/AXUIElement.swift +++ b/Tool/Sources/AXExtension/AXUIElement.swift @@ -84,6 +84,35 @@ public extension AXUIElement { var isHidden: Bool { (try? copyValue(key: kAXHiddenAttribute)) ?? false } + + var debugDescription: String { + "<\(title)> <\(description)> <\(label)> (\(role):\(roleDescription)) [\(identifier)] \(rect ?? .zero) \(children.count) children" + } + + var debugEnumerateChildren: String { + var result = "> " + debugDescription + "\n" + result += children.map { + $0.debugEnumerateChildren.split(separator: "\n") + .map { " " + $0 } + .joined(separator: "\n") + }.joined(separator: "\n") + return result + } + + var debugEnumerateParents: String { + var chain: [String] = [] + chain.append("* " + debugDescription) + var parent = self.parent + if let current = parent { + chain.append("> " + current.debugDescription) + parent = current.parent + } + var result = "" + for (index, line) in chain.reversed().enumerated() { + result += String(repeating: " ", count: index) + line + "\n" + } + return result + } } // MARK: - Rect @@ -138,6 +167,15 @@ public extension AXUIElement { (try? copyValue(key: "AXFullScreen")) ?? false } + var windowID: CGWindowID? { + var identifier: CGWindowID = 0 + let error = AXUIElementGetWindow(self, &identifier) + if error == .success { + return identifier + } + return nil + } + var isFrontmost: Bool { get { (try? copyValue(key: kAXFrontmostAttribute)) ?? false @@ -227,7 +265,7 @@ public extension AXUIElement { fatalError("AXUIElement.children: Exceeding recommended depth.") } #endif - + var all = [AXUIElement]() for child in children { if match(child) { all.append(child) } @@ -244,11 +282,19 @@ public extension AXUIElement { return parent.firstParent(where: match) } - func firstChild(depth: Int = 0, where match: (AXUIElement) -> Bool) -> AXUIElement? { + func firstChild( + depth: Int = 0, + maxDepth: Int = 50, + where match: (AXUIElement) -> Bool + ) -> AXUIElement? { #if DEBUG - if depth >= 50 { + if depth > maxDepth { fatalError("AXUIElement.firstChild: Exceeding recommended depth.") } + #else + if depth > maxDepth { + return nil + } #endif for child in children { if match(child) { return child } @@ -275,11 +321,11 @@ public extension AXUIElement { } public extension AXUIElement { - enum SearchNextStep { + enum SearchNextStep { case skipDescendants - case skipSiblings + case skipSiblings(Info) case skipDescendantsAndSiblings - case continueSearching + case continueSearching(Info) case stopSearching } @@ -289,26 +335,41 @@ public extension AXUIElement { /// **performance of Xcode**. Please make sure to skip as much as possible. /// /// - todo: Make it not recursive. - func traverse(_ handle: (_ element: AXUIElement, _ level: Int) -> SearchNextStep) { + func traverse( + access: (AXUIElement) -> [AXUIElement] = { $0.children }, + info: Info, + file: StaticString = #file, + line: UInt = #line, + function: StaticString = #function, + _ handle: (_ element: AXUIElement, _ level: Int, _ info: Info) -> SearchNextStep + ) { + #if DEBUG + var count = 0 +// let startDate = Date() + #endif func _traverse( element: AXUIElement, level: Int, - handle: (AXUIElement, Int) -> SearchNextStep - ) -> SearchNextStep { - let nextStep = handle(element, level) + info: Info, + handle: (AXUIElement, Int, Info) -> SearchNextStep + ) -> SearchNextStep { + #if DEBUG + count += 1 + #endif + let nextStep = handle(element, level, info) switch nextStep { case .stopSearching: return .stopSearching - case .skipDescendants: return .continueSearching - case .skipDescendantsAndSiblings: return .skipSiblings - case .continueSearching, .skipSiblings: - for child in element.children { - switch _traverse(element: child, level: level + 1, handle: handle) { + case .skipDescendants: return .continueSearching(info) + case .skipDescendantsAndSiblings: return .skipSiblings(info) + case let .continueSearching(info), let .skipSiblings(info): + loop: for child in access(element) { + switch _traverse(element: child, level: level + 1, info: info, handle: handle) { case .skipSiblings, .skipDescendantsAndSiblings: - break + break loop case .stopSearching: return .stopSearching case .continueSearching, .skipDescendants: - continue + continue loop } } @@ -316,7 +377,37 @@ public extension AXUIElement { } } - _ = _traverse(element: self, level: 0, handle: handle) + _ = _traverse(element: self, level: 0, info: info, handle: handle) + + #if DEBUG +// let duration = Date().timeIntervalSince(startDate) +// .formatted(.number.precision(.fractionLength(0...4))) +// Logger.service.debug( +// "AXUIElement.traverse count: \(count), took \(duration) seconds", +// file: file, +// line: line, +// function: function +// ) + #endif + } + + /// Traversing the element tree. + /// + /// - important: Traversing the element tree is resource consuming and will affect the + /// **performance of Xcode**. Please make sure to skip as much as possible. + /// + /// - todo: Make it not recursive. + func traverse( + access: (AXUIElement) -> [AXUIElement] = { $0.children }, + file: StaticString = #file, + line: UInt = #line, + function: StaticString = #function, + _ handle: (_ element: AXUIElement, _ level: Int) -> SearchNextStep + ) { + traverse(access: access, info: (), file: file, line: line, function: function) { + element, level, _ in + handle(element, level) + } } } diff --git a/Tool/Sources/AXExtension/AXUIElementPrivateAPI.swift b/Tool/Sources/AXExtension/AXUIElementPrivateAPI.swift new file mode 100644 index 00000000..bd861a3f --- /dev/null +++ b/Tool/Sources/AXExtension/AXUIElementPrivateAPI.swift @@ -0,0 +1,8 @@ +import AppKit + +/// AXError _AXUIElementGetWindow(AXUIElementRef element, uint32_t *identifier); +@_silgen_name("_AXUIElementGetWindow") @discardableResult +func AXUIElementGetWindow( + _ element: AXUIElement, + _ identifier: UnsafeMutablePointer +) -> AXError diff --git a/Tool/Sources/AXNotificationStream/AXNotificationStream.swift b/Tool/Sources/AXNotificationStream/AXNotificationStream.swift index 89fca015..b361f8ae 100644 --- a/Tool/Sources/AXNotificationStream/AXNotificationStream.swift +++ b/Tool/Sources/AXNotificationStream/AXNotificationStream.swift @@ -133,9 +133,12 @@ public final class AXNotificationStream: AsyncSequence { .error("AXObserver: Accessibility API disabled, will try again later") retry -= 1 case .invalidUIElement: + // It's possible that the UI element is not ready yet. + // + // Especially when you retrieve an element right after macOS is + // awaken from sleep. Logger.service .error("AXObserver: Invalid UI element, notification name \(name)") - pendingRegistrationNames.remove(name) case .invalidUIElementObserver: Logger.service.error("AXObserver: Invalid UI element observer") pendingRegistrationNames.remove(name) diff --git a/Tool/Sources/ChatBasic/ChatAgent.swift b/Tool/Sources/ChatBasic/ChatAgent.swift index 763233b9..1b6b835d 100644 --- a/Tool/Sources/ChatBasic/ChatAgent.swift +++ b/Tool/Sources/ChatBasic/ChatAgent.swift @@ -33,17 +33,20 @@ public struct ChatAgentRequest { public var history: [ChatMessage] public var references: [ChatMessage.Reference] public var topics: [ChatMessage.Reference] + public var agentInstructions: String? = nil public init( text: String, history: [ChatMessage], references: [ChatMessage.Reference], - topics: [ChatMessage.Reference] + topics: [ChatMessage.Reference], + agentInstructions: String? = nil ) { self.text = text self.history = history self.references = references self.topics = topics + self.agentInstructions = agentInstructions } } diff --git a/Tool/Sources/ChatBasic/ChatGPTFunction.swift b/Tool/Sources/ChatBasic/ChatGPTFunction.swift index 6824ff38..2a5a4af0 100644 --- a/Tool/Sources/ChatBasic/ChatGPTFunction.swift +++ b/Tool/Sources/ChatBasic/ChatGPTFunction.swift @@ -7,12 +7,39 @@ public enum ChatGPTFunctionCallPhase { case error(argumentsJsonString: String, result: Error) } +public enum ChatGPTFunctionResultUserReadableContent: Sendable { + public struct ListItem: Sendable { + public enum Detail: Sendable { + case link(URL) + case text(String) + } + + public var title: String + public var description: String? + public var detail: Detail? + + public init(title: String, description: String? = nil, detail: Detail? = nil) { + self.title = title + self.description = description + self.detail = detail + } + } + + case text(String) + case list([ListItem]) + case searchResult([ListItem], queries: [String]) +} + public protocol ChatGPTFunctionResult { var botReadableContent: String { get } + var userReadableContent: ChatGPTFunctionResultUserReadableContent { get } } extension String: ChatGPTFunctionResult { public var botReadableContent: String { self } + public var userReadableContent: ChatGPTFunctionResultUserReadableContent { + .text(self) + } } public struct NoChatGPTFunctionArguments: Decodable {} diff --git a/Tool/Sources/ChatBasic/ChatPlugin.swift b/Tool/Sources/ChatBasic/ChatPlugin.swift index e6e0ef91..cd5977a8 100644 --- a/Tool/Sources/ChatBasic/ChatPlugin.swift +++ b/Tool/Sources/ChatBasic/ChatPlugin.swift @@ -1,6 +1,6 @@ import Foundation -public struct ChatPluginRequest { +public struct ChatPluginRequest: Sendable { public var text: String public var arguments: [String] public var history: [ChatMessage] diff --git a/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/GetCodeCodeAroundLineFunction.swift b/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/GetCodeCodeAroundLineFunction.swift index 7ed337cc..26fbe579 100644 --- a/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/GetCodeCodeAroundLineFunction.swift +++ b/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/GetCodeCodeAroundLineFunction.swift @@ -22,6 +22,10 @@ struct GetCodeCodeAroundLineFunction: ChatGPTFunction { ``` """ } + + var userReadableContent: ChatGPTFunctionResultUserReadableContent { + .text(botReadableContent) + } } struct E: Error, LocalizedError { diff --git a/Tool/Sources/CodeiumService/LanguageServer/CodeiumInstallationManager.swift b/Tool/Sources/CodeiumService/LanguageServer/CodeiumInstallationManager.swift index 295a4467..29936bf8 100644 --- a/Tool/Sources/CodeiumService/LanguageServer/CodeiumInstallationManager.swift +++ b/Tool/Sources/CodeiumService/LanguageServer/CodeiumInstallationManager.swift @@ -3,7 +3,7 @@ import Terminal public struct CodeiumInstallationManager { private static var isInstalling = false - static let latestSupportedVersion = "1.46.3" + static let latestSupportedVersion = "1.48.2" static let minimumSupportedVersion = "1.20.0" public init() {} diff --git a/Tool/Sources/CustomAsyncAlgorithms/TimedDebounce.swift b/Tool/Sources/CustomAsyncAlgorithms/TimedDebounce.swift index df296cc8..bb97d82f 100644 --- a/Tool/Sources/CustomAsyncAlgorithms/TimedDebounce.swift +++ b/Tool/Sources/CustomAsyncAlgorithms/TimedDebounce.swift @@ -46,18 +46,27 @@ public extension AsyncSequence { /// /// In the future when we drop macOS 12 support we should just use chunked from AsyncAlgorithms. func timedDebounce( - for duration: TimeInterval + for duration: TimeInterval, + reducer: @escaping @Sendable (Element, Element) -> Element ) -> AsyncThrowingStream { return AsyncThrowingStream { continuation in Task { - let function = TimedDebounceFunction(duration: duration) { value in - continuation.yield(value) - } + let storage = TimedDebounceStorage() + var lastTimeStamp = Date() do { for try await value in self { - await function(value) + await storage.reduce(value, reducer: reducer) + let now = Date() + if now.timeIntervalSince(lastTimeStamp) >= duration { + lastTimeStamp = now + if let value = await storage.consume() { + continuation.yield(value) + } + } + } + if let value = await storage.consume() { + continuation.yield(value) } - await function.finish() continuation.finish() } catch { continuation.finish(throwing: error) @@ -67,3 +76,19 @@ public extension AsyncSequence { } } +private actor TimedDebounceStorage { + var value: Element? + func reduce(_ value: Element, reducer: (Element, Element) -> Element) async { + if let existing = self.value { + self.value = reducer(existing, value) + } else { + self.value = value + } + } + + func consume() -> Element? { + defer { value = nil } + return value + } +} + diff --git a/Tool/Sources/CustomCommandTemplateProcessor/CustomCommandTemplateProcessor.swift b/Tool/Sources/CustomCommandTemplateProcessor/CustomCommandTemplateProcessor.swift index 9f648612..891c8301 100644 --- a/Tool/Sources/CustomCommandTemplateProcessor/CustomCommandTemplateProcessor.swift +++ b/Tool/Sources/CustomCommandTemplateProcessor/CustomCommandTemplateProcessor.swift @@ -39,7 +39,7 @@ public struct CustomCommandTemplateProcessor { } func getEditorInformation() async -> EditorInformation { - let editorContent = await XcodeInspector.shared.focusedEditor?.getContent() + let editorContent = await XcodeInspector.shared.latestFocusedEditor?.getContent() let documentURL = await XcodeInspector.shared.activeDocumentURL let language = documentURL.map(languageIdentifierFromFileURL) ?? .plaintext diff --git a/Tool/Sources/DebounceFunction/ThrottleFunction.swift b/Tool/Sources/DebounceFunction/ThrottleFunction.swift index 52a4f0ca..d0532397 100644 --- a/Tool/Sources/DebounceFunction/ThrottleFunction.swift +++ b/Tool/Sources/DebounceFunction/ThrottleFunction.swift @@ -8,7 +8,7 @@ public actor ThrottleFunction { var lastFinishTime: Date = .init(timeIntervalSince1970: 0) var now: () -> Date = { Date() } - public init(duration: TimeInterval, block: @escaping (T) async -> Void) { + public init(duration: TimeInterval, block: @escaping @Sendable (T) async -> Void) { self.duration = duration self.block = block } @@ -50,13 +50,13 @@ public actor ThrottleRunner { self.duration = duration } - public func throttle(block: @escaping () async -> Void) { + public func throttle(block: @escaping @Sendable () async -> Void) { if task == nil { scheduleTask(wait: now().timeIntervalSince(lastFinishTime) < duration, block: block) } } - func scheduleTask(wait: Bool, block: @escaping () async -> Void) { + func scheduleTask(wait: Bool, block: @escaping @Sendable () async -> Void) { task = Task.detached { [weak self] in guard let self else { return } do { diff --git a/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift index 8ff625f8..606499a4 100644 --- a/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift +++ b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift @@ -1,9 +1,11 @@ import BuiltinExtension import CopilotForXcodeKit +import Dependencies import Foundation import LanguageServerProtocol import Logger import Preferences +import Toast import Workspace public final class GitHubCopilotExtension: BuiltinExtension { @@ -20,6 +22,8 @@ public final class GitHubCopilotExtension: BuiltinExtension { extensionUsage.isSuggestionServiceInUse || extensionUsage.isChatServiceInUse } + @Dependency(\.toastController) var toast + let workspacePool: WorkspacePool let serviceLocator: ServiceLocatorType @@ -49,6 +53,16 @@ public final class GitHubCopilotExtension: BuiltinExtension { let content = try String(contentsOf: documentURL, encoding: .utf8) guard let service = await serviceLocator.getService(from: workspace) else { return } try await service.notifyOpenTextDocument(fileURL: documentURL, content: content) + } catch let error as ServerError { + let e = GitHubCopilotError.languageServerError(error) + Logger.gitHubCopilot.error(e.localizedDescription) + + switch error { + case .serverUnavailable, .serverError: + toast.toast(content: e.localizedDescription, type: .error, duration: 10.0) + default: + break + } } catch { Logger.gitHubCopilot.error(error.localizedDescription) } @@ -295,7 +309,7 @@ extension GitHubCopilotExtension { var id: String var capabilities: Capability } - + struct Body: Decodable { var data: [Model] } diff --git a/Tool/Sources/GitHubCopilotService/GitHubCopilotWorkspacePlugin.swift b/Tool/Sources/GitHubCopilotService/GitHubCopilotWorkspacePlugin.swift index e893c133..405a5402 100644 --- a/Tool/Sources/GitHubCopilotService/GitHubCopilotWorkspacePlugin.swift +++ b/Tool/Sources/GitHubCopilotService/GitHubCopilotWorkspacePlugin.swift @@ -1,8 +1,8 @@ +import Dependencies import Foundation import Logger -import Workspace import Toast -import Dependencies +import Workspace public final class GitHubCopilotWorkspacePlugin: WorkspacePlugin { enum Error: Swift.Error, LocalizedError { @@ -14,7 +14,7 @@ public final class GitHubCopilotWorkspacePlugin: WorkspacePlugin { } } } - + @Dependency(\.toast) var toast let installationManager = GitHubCopilotInstallationManager() @@ -32,6 +32,10 @@ public final class GitHubCopilotWorkspacePlugin: WorkspacePlugin { return nil } catch { Logger.gitHubCopilot.error("Failed to create GitHub Copilot service: \(error)") + toast( + "Failed to start GitHub Copilot language server: \(error.localizedDescription)", + .error + ) return nil } } @@ -74,7 +78,7 @@ public final class GitHubCopilotWorkspacePlugin: WorkspacePlugin { } } } - + @GitHubCopilotSuggestionActor func updateLanguageServerIfPossible() async { guard !GitHubCopilotInstallationManager.isInstalling else { return } diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotInstallationManager.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotInstallationManager.swift index 2971786e..edf59a50 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotInstallationManager.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotInstallationManager.swift @@ -6,7 +6,7 @@ public struct GitHubCopilotInstallationManager { public private(set) static var isInstalling = false static var downloadURL: URL { - let commitHash = "18f485d892b56b311fd752039d6977333ebc2a0f" + let commitHash = "f89e977c87180519ba3b942200e3d05b17b1e2fc" let link = "https://github.com/github/copilot.vim/archive/\(commitHash).zip" return URL(string: link)! } @@ -14,7 +14,7 @@ public struct GitHubCopilotInstallationManager { /// The GitHub's version has quite a lot of changes about `watchedFiles` since the following /// commit. /// https://github.com/github/CopilotForXcode/commit/a50045aa3ab3b7d532cadf40c4c10bed32f81169#diff-678798cf677bcd1ce276809cfccd33da9ff594b1b0c557180210a4ed2bd27ffa - static let latestSupportedVersion = "1.48.0" + static let latestSupportedVersion = "1.57.0" static let minimumSupportedVersion = "1.32.0" public init() {} @@ -151,7 +151,15 @@ public struct GitHubCopilotInstallationManager { return } - let lspURL = gitFolderURL.appendingPathComponent("dist") + let lspURL = { + let caseA = gitFolderURL.appendingPathComponent("dist") + if FileManager.default.fileExists(atPath: caseA.path) { + return caseA + } + return gitFolderURL + .appendingPathComponent("copilot-language-server") + .appendingPathComponent("dist") + }() let copilotURL = urls.executableURL.appendingPathComponent("copilot") if !FileManager.default.fileExists(atPath: copilotURL.path) { diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift index 2f874200..486ddd92 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift @@ -83,7 +83,7 @@ enum GitHubCopilotError: Error, LocalizedError { case let .clientDataUnavailable(error): return "Language server error: Client data unavailable: \(error)" case .serverUnavailable: - return "Language server error: Server unavailable, please make sure that:\n1. The path to node is correctly set.\n2. The node is not a shim executable.\n3. the node version is high enough." + return "Language server error: Server unavailable, please make sure that:\n1. The path to node is correctly set.\n2. The node is not a shim executable.\n3. the node version is high enough (v22.0+)." case .missingExpectedParameter: return "Language server error: Missing expected parameter" case .missingExpectedResult: @@ -523,11 +523,23 @@ public final class GitHubCopilotService: GitHubCopilotBaseService, // 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 { + try await notifyChangeTextDocument( + fileURL: fileURL, + content: content, + version: 1 + ) + } catch let error as ServerError { + switch error { + case .serverUnavailable: + throw SuggestionServiceError + .notice(GitHubCopilotError.languageServerError(error)) + default: + throw error + } + } catch { + throw error + } do { try Task.checkCancellation() diff --git a/Tool/Sources/Logger/Logger.swift b/Tool/Sources/Logger/Logger.swift index 2d791376..58d280f0 100644 --- a/Tool/Sources/Logger/Logger.swift +++ b/Tool/Sources/Logger/Logger.swift @@ -53,7 +53,11 @@ public final class Logger { osLogType = .error } + #if DEBUG + os_log("%{public}@", log: osLog, type: osLogType, "\(file):\(line) \(function)\n\n\(message)" as CVarArg) + #else os_log("%{public}@", log: osLog, type: osLogType, message as CVarArg) + #endif } public func debug( diff --git a/Tool/Sources/ModificationBasic/ExplanationThenCodeStreamParser.swift b/Tool/Sources/ModificationBasic/ExplanationThenCodeStreamParser.swift new file mode 100644 index 00000000..bb3a71e9 --- /dev/null +++ b/Tool/Sources/ModificationBasic/ExplanationThenCodeStreamParser.swift @@ -0,0 +1,269 @@ +import Foundation + +/// Parse a stream that contains explanation followed by a code block. +public actor ExplanationThenCodeStreamParser { + enum State { + case explanation + case code + case codeOpening + case codeClosing + } + + public enum Fragment: Sendable { + case explanation(String) + case code(String) + } + + struct Buffer { + var content: String = "" + } + + var _buffer: Buffer = .init() + var isAtBeginning = true + var buffer: String { _buffer.content } + var state: State = .explanation + let fullCodeDelimiter = "```" + + public init() {} + + private func appendBuffer(_ character: Character) { + _buffer.content.append(character) + } + + private func appendBuffer(_ content: String) { + _buffer.content += content + } + + private func resetBuffer() { + _buffer.content = "" + } + + func flushBuffer() -> String? { + if buffer.isEmpty { return nil } + guard let targetIndex = _buffer.content.lastIndex(where: { $0 != "`" && !$0.isNewline }) + else { return nil } + let prefix = _buffer.content[...targetIndex] + if prefix.isEmpty { return nil } + let nextIndex = _buffer.content.index( + targetIndex, + offsetBy: 1, + limitedBy: _buffer.content.endIndex + ) ?? _buffer.content.endIndex + + if nextIndex == _buffer.content.endIndex { + _buffer.content = "" + } else { + _buffer.content = String( + _buffer.content[nextIndex...] + ) + } + + // If we flushed something, we are no longer at the beginning + isAtBeginning = false + return String(prefix) + } + + func flushBufferIfNeeded(into results: inout [Fragment]) { + switch state { + case .explanation: + if let flushed = flushBuffer() { + results.append(.explanation(flushed)) + } + case .code: + if let flushed = flushBuffer() { + results.append(.code(flushed)) + } + case .codeOpening, .codeClosing: + break + } + } + + public func yield(_ fragment: String) -> [Fragment] { + var results: [Fragment] = [] + + func flushBuffer() { + flushBufferIfNeeded(into: &results) + } + + for character in fragment { + switch state { + case .explanation: + func forceFlush() { + if !buffer.isEmpty { + isAtBeginning = false + results.append(.explanation(buffer)) + resetBuffer() + } + } + + switch character { + case "`": + if let last = buffer.last, last == "`" || last.isNewline { + flushBuffer() + // if we are seeing the pattern of "\n`" or "``" + // that mean we may be hitting a code delimiter + appendBuffer(character) + let shouldOpenCodeBlock: Bool = { + guard buffer.hasSuffix(fullCodeDelimiter) + else { return false } + if isAtBeginning { return true } + let temp = String(buffer.dropLast(fullCodeDelimiter.count)) + if let last = temp.last, last.isNewline { + return true + } + return false + }() + // if we meet a code delimiter while in explanation state, + // it means we are opening a code block + if shouldOpenCodeBlock { + results.append(.explanation( + String(buffer.dropLast(fullCodeDelimiter.count)) + .trimmingTrailingCharacters(in: .whitespacesAndNewlines) + )) + resetBuffer() + state = .codeOpening + } + } else { + // Otherwise, the backtick is probably part of the explanation. + forceFlush() + appendBuffer(character) + } + case let char where char.isNewline: + // we keep the trailing new lines in case they are right + // ahead of the code block that should be ignored. + if let last = buffer.last, last.isNewline { + flushBuffer() + appendBuffer(character) + } else { + forceFlush() + appendBuffer(character) + } + default: + appendBuffer(character) + } + case .code: + func forceFlush() { + if !buffer.isEmpty { + isAtBeginning = false + results.append(.code(buffer)) + resetBuffer() + } + } + + switch character { + case "`": + if let last = buffer.last, last == "`" || last.isNewline { + flushBuffer() + // if we are seeing the pattern of "\n`" or "``" + // that mean we may be hitting a code delimiter + appendBuffer(character) + let possibleClosingDelimiter: String? = { + guard buffer.hasSuffix(fullCodeDelimiter) else { return nil } + let temp = String(buffer.dropLast(fullCodeDelimiter.count)) + if let last = temp.last, last.isNewline { + return "\(last)\(fullCodeDelimiter)" + } + return nil + }() + // if we meet a code delimiter while in code state, + // // it means we are closing the code block + if let possibleClosingDelimiter { + results.append(.code( + String(buffer.dropLast(possibleClosingDelimiter.count)) + )) + resetBuffer() + appendBuffer(possibleClosingDelimiter) + state = .codeClosing + } + } else { + // Otherwise, the backtick is probably part of the code. + forceFlush() + appendBuffer(character) + } + + case let char where char.isNewline: + if let last = buffer.last, last.isNewline { + flushBuffer() + appendBuffer(character) + } else { + forceFlush() + appendBuffer(character) + } + default: + appendBuffer(character) + } + case .codeOpening: + // skip the code block fence + if character.isNewline { + state = .code + } + case .codeClosing: + appendBuffer(character) + switch character { + case "`": + let possibleClosingDelimiter: String? = { + guard buffer.hasSuffix(fullCodeDelimiter) else { return nil } + let temp = String(buffer.dropLast(fullCodeDelimiter.count)) + if let last = temp.last, last.isNewline { + return "\(last)\(fullCodeDelimiter)" + } + return nil + }() + // if we meet another code delimiter while in codeClosing state, + // it means the previous code delimiter was part of the code + if let possibleClosingDelimiter { + results.append(.code( + String(buffer.dropLast(possibleClosingDelimiter.count)) + )) + resetBuffer() + appendBuffer(possibleClosingDelimiter) + } + default: + break + } + } + } + + flushBuffer() + + return results + } + + public func finish() -> [Fragment] { + guard !buffer.isEmpty else { return [] } + + var results: [Fragment] = [] + switch state { + case .explanation: + results.append( + .explanation(buffer.trimmingTrailingCharacters(in: .whitespacesAndNewlines)) + ) + case .code: + results.append(.code(buffer)) + case .codeClosing: + break + case .codeOpening: + break + } + resetBuffer() + + return results + } +} + +extension String { + func trimmingTrailingCharacters(in characterSet: CharacterSet) -> String { + guard !isEmpty else { + return "" + } + var unicodeScalars = unicodeScalars + while let scalar = unicodeScalars.last { + if !characterSet.contains(scalar) { + return String(unicodeScalars) + } + unicodeScalars.removeLast() + } + return "" + } +} + diff --git a/Tool/Sources/ModificationBasic/ModificationAgent.swift b/Tool/Sources/ModificationBasic/ModificationAgent.swift index 69a409f7..a29224af 100644 --- a/Tool/Sources/ModificationBasic/ModificationAgent.swift +++ b/Tool/Sources/ModificationBasic/ModificationAgent.swift @@ -5,6 +5,7 @@ import SuggestionBasic public enum ModificationAgentResponse { case code(String) + case explanation(String) } public struct ModificationAgentRequest { diff --git a/Tool/Sources/OpenAIService/APIs/ClaudeChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/ClaudeChatCompletionsService.swift index 0b41c053..223eab79 100644 --- a/Tool/Sources/OpenAIService/APIs/ClaudeChatCompletionsService.swift +++ b/Tool/Sources/OpenAIService/APIs/ClaudeChatCompletionsService.swift @@ -7,6 +7,7 @@ import JoinJSON import Logger import Preferences +#warning("Update the definitions") /// https://docs.anthropic.com/claude/reference/messages_post public actor ClaudeChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI { /// https://docs.anthropic.com/en/docs/about-claude/models @@ -44,7 +45,7 @@ public actor ClaudeChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet } } - enum MessageRole: String, Codable { + public enum MessageRole: String, Codable { case user case assistant @@ -127,7 +128,7 @@ public actor ClaudeChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet var stop_sequence: String? } - public struct RequestBody: Encodable, Equatable { + public struct RequestBody: Codable, Equatable { public struct CacheControl: Codable, Equatable, Sendable { public enum CacheControlType: String, Codable, Equatable, Sendable { case ephemeral @@ -136,33 +137,33 @@ public actor ClaudeChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet public var type: CacheControlType = .ephemeral } - struct MessageContent: Encodable, Equatable { - enum MessageContentType: String, Encodable, Equatable { + public struct MessageContent: Codable, Equatable { + public enum MessageContentType: String, Codable, Equatable { case text case image } - struct ImageSource: Encodable, Equatable { - var type: String = "base64" + public struct ImageSource: Codable, Equatable { + public var type: String = "base64" /// currently support the base64 source type for images, /// and the image/jpeg, image/png, image/gif, and image/webp media types. - var media_type: String = "image/jpeg" - var data: String + public var media_type: String = "image/jpeg" + public var data: String } - var type: MessageContentType - var text: String? - var source: ImageSource? - var cache_control: CacheControl? + public var type: MessageContentType + public var text: String? + public var source: ImageSource? + public var cache_control: CacheControl? } - struct Message: Encodable, Equatable { + public struct Message: Codable, Equatable { /// The role of the message. - var role: MessageRole + public var role: MessageRole /// The content of the message. - var content: [MessageContent] + public var content: [MessageContent] - mutating func appendText(_ text: String) { + public mutating func appendText(_ text: String) { var otherContents = [MessageContent]() var existedText = "" for existed in content { @@ -182,26 +183,26 @@ public actor ClaudeChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet } } - struct SystemPrompt: Encodable, Equatable { - let type = "text" - var text: String - var cache_control: CacheControl? + public struct SystemPrompt: Codable, Equatable { + public var type = "text" + public var text: String + public var cache_control: CacheControl? } - struct Tool: Encodable, Equatable { - var name: String - var description: String - var input_schema: JSONSchemaValue + public struct Tool: Codable, Equatable { + public var name: String + public var description: String + public var input_schema: JSONSchemaValue } - var model: String - var system: [SystemPrompt] - var messages: [Message] - var temperature: Double? - var stream: Bool? - var stop_sequences: [String]? - var max_tokens: Int - var tools: [RequestBody.Tool]? + public var model: String + public var system: [SystemPrompt] + public var messages: [Message] + public var temperature: Double? + public var stream: Bool? + public var stop_sequences: [String]? + public var max_tokens: Int + public var tools: [RequestBody.Tool]? } var apiKey: String @@ -521,5 +522,53 @@ extension ClaudeChatCompletionsService.RequestBody { stop_sequences = body.stop max_tokens = body.maxTokens ?? 4000 } + + func formalized() -> ChatCompletionsRequestBody { + return .init( + model: model, + messages: system.map { system in + let convertedMessage = ChatCompletionsRequestBody.Message( + role: .system, + content: system.text, + cacheIfPossible: system.cache_control != nil + ) + return convertedMessage + } + messages.map { message in + var convertedMessage = ChatCompletionsRequestBody.Message( + role: message.role == .user ? .user : .assistant, + content: "", + cacheIfPossible: message.content.contains(where: { $0.cache_control != nil }) + ) + for messageContent in message.content { + switch messageContent.type { + case .text: + if let text = messageContent.text { + convertedMessage.content += text + } + case .image: + if let source = messageContent.source { + convertedMessage.images.append( + .init( + base64EncodeData: source.data, + format: { + switch source.media_type { + case "image/png": return .png + case "image/gif": return .gif + default: return .jpeg + } + }() + ) + ) + } + } + } + return convertedMessage + }, + temperature: temperature, + stream: stream, + stop: stop_sequences, + maxTokens: max_tokens + ) + } } diff --git a/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift index bf1e22b0..93c5987d 100644 --- a/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift +++ b/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift @@ -74,10 +74,12 @@ public actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet case assistant case function case tool + case developer var formalized: ChatCompletionsRequestBody.Message.Role { switch self { case .system: return .system + case .developer: return .system case .user: return .user case .assistant: return .assistant case .function: return .tool @@ -272,6 +274,14 @@ public actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet public struct RequestBody: Codable, Equatable { public typealias ClaudeCacheControl = ClaudeChatCompletionsService.RequestBody.CacheControl + public struct GitHubCopilotCacheControl: Codable, Equatable, Sendable { + public var type: String + + public init(type: String = "ephemeral") { + self.type = type + } + } + public struct Message: Codable, Equatable { public enum MessageContent: Codable, Equatable { public struct TextContentPart: Codable, Equatable { @@ -427,6 +437,14 @@ public actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet errors.append(error) } + do { // Null + _ = try container.decode([ContentPart]?.self) + self = .contentParts([]) + return + } catch { + errors.append(error) + } + struct E: Error, LocalizedError { let errors: [Error] @@ -457,6 +475,9 @@ public actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet /// /// Deprecated. public var function_call: MessageFunctionCall? + #warning("TODO: when to use it?") + /// Cache control for GitHub Copilot models. + public var copilot_cache_control: GitHubCopilotCacheControl? public init( role: MessageRole, @@ -464,7 +485,8 @@ public actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet name: String? = nil, tool_calls: [MessageToolCall]? = nil, tool_call_id: String? = nil, - function_call: MessageFunctionCall? = nil + function_call: MessageFunctionCall? = nil, + copilot_cache_control: GitHubCopilotCacheControl? = nil ) { self.role = role self.content = content @@ -472,6 +494,7 @@ public actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet self.tool_calls = tool_calls self.tool_call_id = tool_call_id self.function_call = function_call + self.copilot_cache_control = copilot_cache_control } } @@ -623,6 +646,7 @@ public actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet Self.setupCustomBody(&request, model: model) Self.setupAppInformation(&request) Self.setupAPIKey(&request, model: model, apiKey: apiKey) + Self.setupGitHubCopilotVisionField(&request, model: model) await Self.setupExtraHeaderFields(&request, model: model, apiKey: apiKey) requestModifier?(&request) @@ -772,6 +796,12 @@ public actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet } } + static func setupGitHubCopilotVisionField(_ request: inout URLRequest, model: ChatModel) { + if model.info.supportsImage { + request.setValue("true", forHTTPHeaderField: "copilot-vision-request") + } + } + static func setupCustomBody(_ request: inout URLRequest, model: ChatModel) { switch model.format { case .openAI, .openAICompatible: @@ -980,7 +1010,7 @@ extension OpenAIChatCompletionsService.RequestBody { ) { if supportsMultipartMessageContent { switch message.role { - case .system, .assistant, .user: + case .system, .developer, .assistant, .user: let newParts = Self.convertContentPart( content: content, images: images, @@ -1000,7 +1030,7 @@ extension OpenAIChatCompletionsService.RequestBody { } } else { switch message.role { - case .system, .assistant, .user: + case .system, .developer, .assistant, .user: if case let .text(existingText) = message.content { message.content = .text(existingText + "\n\n" + content) } else { @@ -1242,12 +1272,17 @@ extension OpenAIChatCompletionsService.RequestBody { } }(), content: { + // always prefer text only content if possible. if supportsMultipartMessageContent { - return .contentParts(Self.convertContentPart( - content: message.content, - images: supportsImage ? message.images : [], - audios: supportsAudio ? message.audios : [] - )) + let images = supportsImage ? message.images : [] + let audios = supportsAudio ? message.audios : [] + if !images.isEmpty || !audios.isEmpty { + return .contentParts(Self.convertContentPart( + content: message.content, + images: images, + audios: audios + )) + } } return .text(message.content) }(), diff --git a/Tool/Sources/OpenAIService/APIs/OpenAIResponsesRawService.swift b/Tool/Sources/OpenAIService/APIs/OpenAIResponsesRawService.swift new file mode 100644 index 00000000..818c4616 --- /dev/null +++ b/Tool/Sources/OpenAIService/APIs/OpenAIResponsesRawService.swift @@ -0,0 +1,235 @@ +import AIModel +import AsyncAlgorithms +import ChatBasic +import Foundation +import GitHubCopilotService +import JoinJSON +import Logger +import Preferences + +/// https://platform.openai.com/docs/api-reference/responses/create +public actor OpenAIResponsesRawService { + struct CompletionAPIError: Error, Decodable, LocalizedError { + struct ErrorDetail: Decodable { + var message: String + var type: String? + var param: String? + var code: String? + } + + struct MistralAIErrorMessage: Decodable { + struct Detail: Decodable { + var msg: String? + } + + var message: String? + var msg: String? + var detail: [Detail]? + } + + enum Message { + case raw(String) + case mistralAI(MistralAIErrorMessage) + } + + var error: ErrorDetail? + var message: Message + + var errorDescription: String? { + if let message = error?.message { return message } + switch message { + case let .raw(string): + return string + case let .mistralAI(mistralAIErrorMessage): + return mistralAIErrorMessage.message + ?? mistralAIErrorMessage.msg + ?? mistralAIErrorMessage.detail?.first?.msg + ?? "Unknown Error" + } + } + + enum CodingKeys: String, CodingKey { + case error + case message + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + error = try container.decode(ErrorDetail.self, forKey: .error) + message = { + if let e = try? container.decode(MistralAIErrorMessage.self, forKey: .message) { + return CompletionAPIError.Message.mistralAI(e) + } + if let e = try? container.decode(String.self, forKey: .message) { + return .raw(e) + } + return .raw("Unknown Error") + }() + } + } + + var apiKey: String + var endpoint: URL + var requestBody: [String: Any] + var model: ChatModel + let requestModifier: ((inout URLRequest) -> Void)? + + public init( + apiKey: String, + model: ChatModel, + endpoint: URL, + requestBody: Data, + requestModifier: ((inout URLRequest) -> Void)? = nil + ) { + self.apiKey = apiKey + self.endpoint = endpoint + self.requestBody = ( + try? JSONSerialization.jsonObject(with: requestBody) as? [String: Any] + ) ?? [:] + self.requestBody["model"] = model.info.modelName + self.model = model + self.requestModifier = requestModifier + } + + public func callAsFunction() async throws + -> URLSession.AsyncBytes + { + requestBody["stream"] = true + var request = URLRequest(url: endpoint) + request.httpMethod = "POST" + request.httpBody = try JSONSerialization.data( + withJSONObject: requestBody, + options: [] + ) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + Self.setupAppInformation(&request) + await Self.setupAPIKey(&request, model: model, apiKey: apiKey) + Self.setupGitHubCopilotVisionField(&request, model: model) + await Self.setupExtraHeaderFields(&request, model: model, apiKey: apiKey) + requestModifier?(&request) + + let (result, response) = try await URLSession.shared.bytes(for: request) + guard let response = response as? HTTPURLResponse else { + throw ChatGPTServiceError.responseInvalid + } + + guard response.statusCode == 200 else { + let text = try await result.lines.reduce(into: "") { partialResult, current in + partialResult += current + } + guard let data = text.data(using: .utf8) + else { throw ChatGPTServiceError.responseInvalid } + if response.statusCode == 403 { + throw ChatGPTServiceError.unauthorized(text) + } + let decoder = JSONDecoder() + let error = try? decoder.decode(CompletionAPIError.self, from: data) + throw error ?? ChatGPTServiceError.otherError( + text + + "\n\nPlease check your model settings, some capabilities may not be supported by the model." + ) + } + + return result + } + + public func callAsFunction() async throws -> Data { + let stream: URLSession.AsyncBytes = try await callAsFunction() + + return try await stream.reduce(into: Data()) { partialResult, byte in + partialResult.append(byte) + } + } + + static func setupAppInformation(_ request: inout URLRequest) { + if #available(macOS 13.0, *) { + if request.url?.host == "openrouter.ai" { + request.setValue("Copilot for Xcode", forHTTPHeaderField: "X-Title") + request.setValue( + "https://github.com/intitni/CopilotForXcode", + forHTTPHeaderField: "HTTP-Referer" + ) + } + } else { + if request.url?.host == "openrouter.ai" { + request.setValue("Copilot for Xcode", forHTTPHeaderField: "X-Title") + request.setValue( + "https://github.com/intitni/CopilotForXcode", + forHTTPHeaderField: "HTTP-Referer" + ) + } + } + } + + static func setupAPIKey(_ request: inout URLRequest, model: ChatModel, apiKey: String) async { + if !apiKey.isEmpty { + switch model.format { + case .openAI: + if !model.info.openAIInfo.organizationID.isEmpty { + request.setValue( + model.info.openAIInfo.organizationID, + forHTTPHeaderField: "OpenAI-Organization" + ) + } + + if !model.info.openAIInfo.projectID.isEmpty { + request.setValue( + model.info.openAIInfo.projectID, + forHTTPHeaderField: "OpenAI-Project" + ) + } + + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + case .openAICompatible: + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + case .azureOpenAI: + request.setValue(apiKey, forHTTPHeaderField: "api-key") + case .gitHubCopilot: + break + case .googleAI: + assertionFailure("Unsupported") + case .ollama: + assertionFailure("Unsupported") + case .claude: + assertionFailure("Unsupported") + } + } + + if model.format == .gitHubCopilot, + let token = try? await GitHubCopilotExtension.fetchToken() + { + request.setValue( + "Copilot for Xcode/\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown")", + forHTTPHeaderField: "Editor-Version" + ) + request.setValue("Bearer \(token.token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("vscode-chat", forHTTPHeaderField: "Copilot-Integration-Id") + request.setValue("2023-07-07", forHTTPHeaderField: "X-Github-Api-Version") + } + } + + static func setupGitHubCopilotVisionField(_ request: inout URLRequest, model: ChatModel) { + if model.info.supportsImage { + request.setValue("true", forHTTPHeaderField: "copilot-vision-request") + } + } + + static func setupExtraHeaderFields( + _ request: inout URLRequest, + model: ChatModel, + apiKey: String + ) async { + let parser = HeaderValueParser() + for field in model.info.customHeaderInfo.headers where !field.key.isEmpty { + let value = await parser.parse( + field.value, + context: .init(modelName: model.info.modelName, apiKey: apiKey) + ) + request.setValue(value, forHTTPHeaderField: field.key) + } + } +} + diff --git a/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemoryStrategy/AutoManagedChatGPTMemoryOpenAIStrategy.swift b/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemoryStrategy/AutoManagedChatGPTMemoryOpenAIStrategy.swift index d5842b83..a1deaed5 100644 --- a/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemoryStrategy/AutoManagedChatGPTMemoryOpenAIStrategy.swift +++ b/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemoryStrategy/AutoManagedChatGPTMemoryOpenAIStrategy.swift @@ -58,6 +58,10 @@ extension TokenEncoder { } return await group.reduce(0, +) }) + for image in message.images { + encodingContent.append(image.urlString) + total += Int(Double(image.urlString.count) * 1.1) + } return total } diff --git a/Tool/Sources/Preferences/Keys.swift b/Tool/Sources/Preferences/Keys.swift index ec392514..7b955d50 100644 --- a/Tool/Sources/Preferences/Keys.swift +++ b/Tool/Sources/Preferences/Keys.swift @@ -100,6 +100,11 @@ public struct UserDefaultPreferenceKeys { defaultValue: false, key: "InstallBetaBuilds" ) + + public let debugOverlayPanel = PreferenceKey( + defaultValue: false, + key: "DebugOverlayPanel" + ) } // MARK: - OpenAI Account Settings @@ -375,7 +380,7 @@ public extension UserDefaultPreferenceKeys { var acceptSuggestionWithModifierOnlyForSwift: PreferenceKey { .init(defaultValue: false, key: "SuggestionWithModifierOnlyForSwift") } - + var acceptSuggestionLineWithModifierControl: PreferenceKey { .init(defaultValue: true, key: "SuggestionLineWithModifierControl") } @@ -486,7 +491,7 @@ public extension UserDefaultPreferenceKeys { var preferredChatModelIdForUtilities: PreferenceKey { .init(defaultValue: "", key: "PreferredChatModelIdForUtilities") } - + enum ChatPanelFloatOnTopOption: Int, Codable, Equatable { case alwaysOnTop case onTopWhenXcodeIsActive @@ -589,37 +594,37 @@ public extension UserDefaultPreferenceKeys { case headlessBrowser case serpAPI } - + enum SerpAPIEngine: String, Codable, CaseIterable { case google case baidu case bing case duckDuckGo = "duckduckgo" } - + enum HeadlessBrowserEngine: String, Codable, CaseIterable { case google case baidu case bing case duckDuckGo = "duckduckgo" } - + var searchProvider: PreferenceKey { .init(defaultValue: .headlessBrowser, key: "SearchProvider") } - + var serpAPIEngine: PreferenceKey { .init(defaultValue: .google, key: "SerpAPIEngine") } - + var serpAPIKeyName: PreferenceKey { .init(defaultValue: "", key: "SerpAPIKeyName") } - + var headlessBrowserEngine: PreferenceKey { .init(defaultValue: .google, key: "HeadlessBrowserEngine") } - + var bingSearchSubscriptionKey: DeprecatedPreferenceKey { .init(defaultValue: "", key: "BingSearchSubscriptionKey") } diff --git a/Tool/Sources/SharedUIComponents/XcodeStyleFrame.swift b/Tool/Sources/SharedUIComponents/XcodeStyleFrame.swift index 53f2a395..d3f715ea 100644 --- a/Tool/Sources/SharedUIComponents/XcodeStyleFrame.swift +++ b/Tool/Sources/SharedUIComponents/XcodeStyleFrame.swift @@ -24,7 +24,7 @@ public struct XcodeLikeFrame: View { ) // Add an extra border just incase the background is not displayed. .overlay( RoundedRectangle(cornerRadius: max(0, cornerRadius - 1), style: .continuous) - .stroke(Color.white.opacity(0.2), style: .init(lineWidth: 1)) + .stroke(Color.white.opacity(0.1), style: .init(lineWidth: 1)) .padding(1) ) } @@ -32,7 +32,11 @@ public struct XcodeLikeFrame: View { public extension View { func xcodeStyleFrame(cornerRadius: Double? = nil) -> some View { - XcodeLikeFrame(cornerRadius: cornerRadius ?? 10, content: self) + if #available(macOS 26.0, *) { + XcodeLikeFrame(cornerRadius: cornerRadius ?? 14, content: self) + } else { + XcodeLikeFrame(cornerRadius: cornerRadius ?? 10, content: self) + } } } diff --git a/Tool/Sources/SuggestionBasic/EditorInformation.swift b/Tool/Sources/SuggestionBasic/EditorInformation.swift index ca243511..38d6e26d 100644 --- a/Tool/Sources/SuggestionBasic/EditorInformation.swift +++ b/Tool/Sources/SuggestionBasic/EditorInformation.swift @@ -23,6 +23,7 @@ public struct EditorInformation: Sendable { public var lineAnnotations: [LineAnnotation] public var selectedContent: String { + guard !lines.isEmpty else { return "" } if let range = selections.first { if range.isEmpty { return "" } let startIndex = min( @@ -104,8 +105,12 @@ public struct EditorInformation: Sendable { inside range: CursorRange, ignoreColumns: Bool = false ) -> (code: String, lines: [String]) { + if range.start == range.end { + // Empty selection (cursor only): return empty code but include the containing line + return ("", lines(in: code, containing: range)) + } guard range.start < range.end else { return ("", []) } - + let rangeLines = lines(in: code, containing: range) if ignoreColumns { return (rangeLines.joined(), rangeLines) diff --git a/Tool/Sources/Terminal/Terminal.swift b/Tool/Sources/Terminal/Terminal.swift index 6d549616..7ed68789 100644 --- a/Tool/Sources/Terminal/Terminal.swift +++ b/Tool/Sources/Terminal/Terminal.swift @@ -9,6 +9,13 @@ public protocol TerminalType { environment: [String: String] ) -> AsyncThrowingStream + func streamLineForCommand( + _ command: String, + arguments: [String], + currentDirectoryURL: URL?, + environment: [String: String] + ) -> AsyncThrowingStream + func runCommand( _ command: String, arguments: [String], @@ -133,6 +140,46 @@ public final class Terminal: TerminalType, @unchecked Sendable { return contentStream } + public func streamLineForCommand( + _ command: String = "/bin/bash", + arguments: [String], + currentDirectoryURL: URL? = nil, + environment: [String: String] + ) -> AsyncThrowingStream { + let chunkStream = streamCommand( + command, + arguments: arguments, + currentDirectoryURL: currentDirectoryURL, + environment: environment + ) + + return AsyncThrowingStream { continuation in + Task { + var buffer = "" + do { + for try await chunk in chunkStream { + buffer.append(chunk) + + while let range = buffer.range(of: "\n") { + let line = String(buffer[..] = [:] + public init(messages: [Message]) { self.messages = messages } @@ -92,30 +95,54 @@ public class ToastController: ObservableObject { buttons: [Message.MessageButton] = [], duration: TimeInterval = 4 ) { - let id = UUID() - let message = Message( - id: id, - type: type, - namespace: namespace, - content: Text(content), - buttons: buttons.map { b in - Message.MessageButton(label: b.label, action: { [weak self] in - b.action() + Task { @MainActor in + // Find existing message with same content and type (and namespace) + if let existingIndex = messages.firstIndex(where: { + $0.type == type && + $0.content == Text(content) && + $0.namespace == namespace + }) { + let existingMessage = messages[existingIndex] + // Cancel previous removal task + removalTasks[existingMessage.id]?.cancel() + // Start new removal task for this message + removalTasks[existingMessage.id] = Task { @MainActor in + try await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000)) withAnimation(.easeInOut(duration: 0.2)) { - self?.messages.removeAll { $0.id == id } + messages.removeAll { $0.id == existingMessage.id } } - }) + removalTasks.removeValue(forKey: existingMessage.id) + } + return } - ) - - Task { @MainActor in + + let id = UUID() + let message = Message( + id: id, + type: type, + namespace: namespace, + content: Text(content), + buttons: buttons.map { b in + Message.MessageButton(label: b.label, action: { [weak self] in + b.action() + withAnimation(.easeInOut(duration: 0.2)) { + self?.messages.removeAll { $0.id == id } + } + }) + } + ) + withAnimation(.easeInOut(duration: 0.2)) { messages.append(message) messages = messages.suffix(3) } - try await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000)) - withAnimation(.easeInOut(duration: 0.2)) { - messages.removeAll { $0.id == id } + + removalTasks[id] = Task { @MainActor in + try await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000)) + withAnimation(.easeInOut(duration: 0.2)) { + messages.removeAll { $0.id == id } + } + removalTasks.removeValue(forKey: id) } } } @@ -177,4 +204,3 @@ public struct Toast { } } } - diff --git a/Tool/Sources/Workspace/Filespace.swift b/Tool/Sources/Workspace/Filespace.swift index 9f8f9184..be508239 100644 --- a/Tool/Sources/Workspace/Filespace.swift +++ b/Tool/Sources/Workspace/Filespace.swift @@ -88,7 +88,12 @@ public final class Filespace: @unchecked Sendable { } public var presentingSuggestion: CodeSuggestion? { - guard suggestions.endIndex > suggestionIndex, suggestionIndex >= 0 else { return nil } + guard suggestions.endIndex > suggestionIndex, suggestionIndex >= 0 else { + if suggestions.isEmpty { + return nil + } + return suggestions.first + } return suggestions[suggestionIndex] } diff --git a/Tool/Sources/Workspace/WorkspacePool.swift b/Tool/Sources/Workspace/WorkspacePool.swift index 28a7643d..819f1ecc 100644 --- a/Tool/Sources/Workspace/WorkspacePool.swift +++ b/Tool/Sources/Workspace/WorkspacePool.swift @@ -61,6 +61,15 @@ public class WorkspacePool { } public func fetchFilespaceIfExisted(fileURL: URL) -> Filespace? { + // We prefer to get the filespace from the current active workspace. + // Just incase there are multiple workspaces opened with the same file. + if let currentWorkspaceURL = XcodeInspector.shared.realtimeActiveWorkspaceURL { + if let workspace = workspaces[currentWorkspaceURL], + let filespace = workspace.filespaces[fileURL] + { + return filespace + } + } for workspace in workspaces.values { if let filespace = workspace.filespaces[fileURL] { return filespace diff --git a/Tool/Sources/XcodeInspector/AppInstanceInspector.swift b/Tool/Sources/XcodeInspector/AppInstanceInspector.swift index ab358b7e..cd14dc13 100644 --- a/Tool/Sources/XcodeInspector/AppInstanceInspector.swift +++ b/Tool/Sources/XcodeInspector/AppInstanceInspector.swift @@ -2,7 +2,7 @@ import AppKit import Foundation open class AppInstanceInspector: @unchecked Sendable { - let runningApplication: NSRunningApplication + public let runningApplication: NSRunningApplication public let processIdentifier: pid_t public let bundleURL: URL? public let bundleIdentifier: String? diff --git a/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift index 5e73578b..33291631 100644 --- a/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift +++ b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift @@ -245,7 +245,7 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector, @unchecked S } } } else { - let window = XcodeWindowInspector(uiElement: window) + let window = XcodeWindowInspector(app: runningApplication, uiElement: window) focusedWindow = window } } else { @@ -318,14 +318,25 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector, @unchecked S )) } case .uiElementDestroyed: - if isCompletionPanel(notification.element) { - await MainActor.run { - self.completionPanel = nil + let completionPanel = await self.completionPanel + if let completionPanel { + if isCompletionPanel(notification.element) { + await MainActor.run { + self.completionPanel = nil + } + self.axNotifications.send(.init( + kind: .xcodeCompletionPanelChanged, + element: notification.element + )) + } else if completionPanel.parent == nil { + await MainActor.run { + self.completionPanel = nil + } + self.axNotifications.send(.init( + kind: .xcodeCompletionPanelChanged, + element: notification.element + )) } - self.axNotifications.send(.init( - kind: .xcodeCompletionPanelChanged, - element: notification.element - )) } default: continue } @@ -380,6 +391,22 @@ extension XcodeAppInstanceInspector { let workspaces = Self.updateWorkspace(workspaces, with: workspaceInfoInVisibleSpace) self.workspaces = workspaces } + + public func workspaceWindow( + forWorkspaceURL url: URL + ) -> AXUIElement? { + let windows = appElement.windows.filter { $0.identifier == "Xcode.WorkspaceWindow" } + + for window in windows { + if let workspaceURL = WorkspaceXcodeWindowInspector + .extractWorkspaceURL(windowElement: window), + workspaceURL == url + { + return window + } + } + return nil + } /// Use the project path as the workspace identifier. nonisolated static func workspaceIdentifier(_ window: AXUIElement) -> WorkspaceIdentifier { @@ -414,7 +441,7 @@ extension XcodeAppInstanceInspector { allTabs.insert(element.title) return .skipDescendants } - return .continueSearching + return .continueSearching(()) } } return allTabs @@ -469,14 +496,31 @@ private func isCompletionPanel(_ element: AXUIElement) -> Bool { } public extension AXUIElement { + var editorArea: AXUIElement? { + if description == "editor area" { return self } + var area: AXUIElement? + traverse { element, level in + if level > 10 { + return .skipDescendants + } + if element.description == "editor area" { + area = element + return .stopSearching + } + if element.description == "navigator" { + return .skipDescendants + } + + return .continueSearching(()) + } + return area + } + var tabBars: [AXUIElement] { - guard let editArea: AXUIElement = { - if description == "editor area" { return self } - return firstChild(where: { $0.description == "editor area" }) - }() else { return [] } + guard let editorArea else { return [] } var tabBars = [AXUIElement]() - editArea.traverse { element, _ in + editorArea.traverse { element, _ in let description = element.description if description == "Tab Bar" { element.traverse { element, _ in @@ -484,7 +528,7 @@ public extension AXUIElement { tabBars.append(element) return .stopSearching } - return .continueSearching + return .continueSearching(()) } return .skipDescendantsAndSiblings @@ -510,20 +554,17 @@ public extension AXUIElement { return .skipDescendants } - return .continueSearching + return .continueSearching(()) } return tabBars } var debugArea: AXUIElement? { - guard let editArea: AXUIElement = { - if description == "editor area" { return self } - return firstChild(where: { $0.description == "editor area" }) - }() else { return nil } + guard let editorArea else { return nil } var debugArea: AXUIElement? - editArea.traverse { element, _ in + editorArea.traverse { element, _ in let description = element.description if description == "Tab Bar" { return .skipDescendants @@ -550,7 +591,7 @@ public extension AXUIElement { return .skipDescendants } - return .continueSearching + return .continueSearching(()) } return debugArea diff --git a/Tool/Sources/XcodeInspector/XcodeInspector.swift b/Tool/Sources/XcodeInspector/XcodeInspector.swift index d06c69c8..c26b7c7b 100644 --- a/Tool/Sources/XcodeInspector/XcodeInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeInspector.swift @@ -154,6 +154,9 @@ public final class XcodeInspector: Sendable { NotificationCenter.default.post(name: .focusedEditorDidChange, object: nil) } } + + @MainActor + public fileprivate(set) var latestFocusedEditor: SourceEditor? @MainActor public fileprivate(set) var focusedElement: AXUIElement? { @@ -172,7 +175,7 @@ public final class XcodeInspector: Sendable { let projectURL = realtimeActiveProjectURL else { return nil } - let editorContent = await focusedEditor?.getContent() + let editorContent = await latestFocusedEditor?.getContent() let language = languageIdentifierFromFileURL(documentURL) let relativePath = documentURL.path.replacingOccurrences(of: projectURL.path, with: "") @@ -234,6 +237,7 @@ public final class XcodeInspector: Sendable { latestActiveXcode = nil activeApplication = nil focusedEditor = nil + latestFocusedEditor = nil focusedElement = nil } } @@ -389,6 +393,7 @@ public final class XcodeInspector: Sendable { runningApplication: xcode.runningApplication, element: editorElement ) + self.latestFocusedEditor = self.focusedEditor } else if let element = self.focusedElement, let editorElement = element.firstParent(where: \.isSourceEditor) { @@ -396,6 +401,7 @@ public final class XcodeInspector: Sendable { runningApplication: xcode.runningApplication, element: editorElement ) + self.latestFocusedEditor = self.focusedEditor } else { self.focusedEditor = nil } diff --git a/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift b/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift index f23087cc..c63b3f71 100644 --- a/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift @@ -2,23 +2,47 @@ import AppKit import AsyncPassthroughSubject import AXExtension import Combine +import CoreGraphics import Foundation import Logger import Perception public class XcodeWindowInspector { - public let uiElement: AXUIElement + public let app: NSRunningApplication + public let windowID: CGWindowID + public var uiElement: AXUIElement { + let windowID = self.windowID + if _uiElement.parent == nil { + let app = AXUIElementCreateApplication(app.processIdentifier) + app.setMessagingTimeout(2) + if let newWindowElement = app.windows.first(where: { $0.windowID == windowID }) { + self._uiElement = newWindowElement + newWindowElement.setMessagingTimeout(2) + } + } + return _uiElement + } - init(uiElement: AXUIElement) { - self.uiElement = uiElement + var _uiElement: AXUIElement + + init( + app: NSRunningApplication, + uiElement: AXUIElement + ) { + self.app = app + _uiElement = uiElement uiElement.setMessagingTimeout(2) + windowID = uiElement.windowID ?? 0 + } + + public var isInvalid: Bool { + uiElement.parent == nil } } @XcodeInspectorActor @Perceptible public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector, Sendable { - let app: NSRunningApplication @MainActor public private(set) var documentURL: URL = .init(fileURLWithPath: "/") @MainActor @@ -37,8 +61,7 @@ public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector, Sendable uiElement: AXUIElement, axNotifications: AsyncPassthroughSubject ) { - self.app = app - super.init(uiElement: uiElement) + super.init(app: app, uiElement: uiElement) focusedElementChangedTask = Task { @MainActor [weak self, axNotifications] in self?.updateURLs() diff --git a/Tool/Tests/ModificationBasicTests/ExplanationThenCodeStreamParserTests.swift b/Tool/Tests/ModificationBasicTests/ExplanationThenCodeStreamParserTests.swift new file mode 100644 index 00000000..1597c553 --- /dev/null +++ b/Tool/Tests/ModificationBasicTests/ExplanationThenCodeStreamParserTests.swift @@ -0,0 +1,289 @@ +import Foundation +import XCTest +@testable import ModificationBasic + +class ExplanationThenCodeStreamParserTests: XCTestCase { + func collectFragments(_ fragments: [ExplanationThenCodeStreamParser.Fragment]) -> ( + code: String, + explanation: String + ) { + var code = "" + var explanation = "" + for fragment in fragments { + switch fragment { + case let .code(c): + code += c + case let .explanation(e): + explanation += e + } + } + return (code: code, explanation: explanation) + } + + func process(_ code: String) async -> (code: String, explanation: String) { + let parser = ExplanationThenCodeStreamParser() + var allFragments: [ExplanationThenCodeStreamParser.Fragment] = [] + + func chunks(from code: String, chunkSize: Int) -> [String] { + var chunks: [String] = [] + var currentIndex = code.startIndex + + while currentIndex < code.endIndex { + let endIndex = code.index( + currentIndex, + offsetBy: chunkSize, + limitedBy: code.endIndex + ) ?? code.endIndex + let chunk = String(code[currentIndex..