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/ShortcutChatPlugin/ShortcutChatPlugin.swift b/ChatPlugins/Sources/ShortcutChatPlugin/ShortcutChatPlugin.swift index 540a7365..fc9d8d5b 100644 --- a/ChatPlugins/Sources/ShortcutChatPlugin/ShortcutChatPlugin.swift +++ b/ChatPlugins/Sources/ShortcutChatPlugin/ShortcutChatPlugin.swift @@ -20,7 +20,36 @@ public final class ShortcutChatPlugin: ChatPlugin { terminal = Terminal() } - public func send(_ request: Request) async -> AsyncThrowingStream { + public func sendForTextResponse(_ request: Request) async + -> AsyncThrowingStream + { + let stream = await sendForComplicatedResponse(request) + return .init { continuation in + let task = Task { + do { + for try await response in stream { + switch response { + case let .content(.text(content)): + continuation.yield(content) + default: + break + } + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + + continuation.onTermination = { _ in + task.cancel() + } + } + } + + public func sendForComplicatedResponse(_ request: Request) async + -> AsyncThrowingStream + { return .init { continuation in let task = Task { let id = "\(Self.command)-\(UUID().uuidString)" diff --git a/ChatPlugins/Sources/TerminalChatPlugin/TerminalChatPlugin.swift b/ChatPlugins/Sources/TerminalChatPlugin/TerminalChatPlugin.swift index bb3d5028..1360b16d 100644 --- a/ChatPlugins/Sources/TerminalChatPlugin/TerminalChatPlugin.swift +++ b/ChatPlugins/Sources/TerminalChatPlugin/TerminalChatPlugin.swift @@ -5,11 +5,11 @@ 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. """ } @@ -23,44 +23,14 @@ public final class TerminalChatPlugin: ChatPlugin { terminal = Terminal() } - public func formatContent(_ content: Response.Content) -> Response.Content { - switch content { - case let .text(content): - return .text(""" - ```sh - \(content) - ``` - """) - } - } - - public func send(_ request: Request) async -> AsyncThrowingStream { + public func getTextContent(from request: Request) async + -> AsyncStream + { return .init { continuation in let task = Task { - var updateTime = Date() - - func streamOutput(_ content: String) { - defer { updateTime = Date() } - if Date().timeIntervalSince(updateTime) > 60 * 2 { - continuation.yield(.startNewMessage) - continuation.yield(.startAction( - id: "run", - task: "Continue `\(request.text)`" - )) - continuation.yield(.finishAction( - id: "run", - result: .success("Executed.") - )) - continuation.yield(.content(.text("[continue]\n"))) - continuation.yield(.content(.text(content))) - } else { - continuation.yield(.content(.text(content))) - } - } - do { - let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL - let projectURL = await XcodeInspector.shared.safe.realtimeActiveProjectURL + let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL + let projectURL = XcodeInspector.shared.realtimeActiveProjectURL var environment = [String: String]() if let fileURL { @@ -75,8 +45,6 @@ public final class TerminalChatPlugin: ChatPlugin { let env = ProcessInfo.processInfo.environment let shell = env["SHELL"] ?? "/bin/bash" - continuation.yield(.startAction(id: "run", task: "Run `\(request.text)`")) - let output = terminal.streamCommand( shell, arguments: ["-i", "-l", "-c", request.text], @@ -84,25 +52,18 @@ public final class TerminalChatPlugin: ChatPlugin { environment: environment ) - continuation.yield(.finishAction( - id: "run", - result: .success("Executed.") - )) - + var accumulatedOutput = "" for try await content in output { try Task.checkCancellation() - streamOutput(content) + accumulatedOutput += content + continuation.yield(accumulatedOutput) } } catch let error as Terminal.TerminationError { - continuation.yield(.content(.text(""" - - [error: \(error.reason)] - """))) + let errorMessage = "\n\n[error: \(error.reason)]" + continuation.yield(errorMessage) } catch { - continuation.yield(.content(.text(""" - - [error: \(error.localizedDescription)] - """))) + let errorMessage = "\n\n[error: \(error.localizedDescription)]" + continuation.yield(errorMessage) } continuation.finish() @@ -116,5 +77,89 @@ public final class TerminalChatPlugin: ChatPlugin { } } } + + public func sendForTextResponse(_ request: Request) async + -> AsyncThrowingStream + { + let stream = await getTextContent(from: request) + return .init { continuation in + let task = Task { + continuation.yield("Executing command: `\(request.text)`\n\n") + continuation.yield("```console\n") + for await text in stream { + try Task.checkCancellation() + continuation.yield(text) + } + continuation.yield("\n```\n") + continuation.finish() + } + + continuation.onTermination = { _ in + task.cancel() + } + } + } + + public func formatContent(_ content: Response.Content) -> Response.Content { + switch content { + case let .text(content): + return .text(""" + ```console + \(content) + ``` + """) + } + } + + public func sendForComplicatedResponse(_ request: Request) async + -> AsyncThrowingStream + { + return .init { continuation in + let task = Task { + var updateTime = Date() + + continuation.yield(.startAction(id: "run", task: "Run `\(request.text)`")) + + let textStream = await getTextContent(from: request) + var previousOutput = "" + + continuation.yield(.finishAction( + id: "run", + result: .success("Executed.") + )) + + for await accumulatedOutput in textStream { + try Task.checkCancellation() + + let newContent = accumulatedOutput.dropFirst(previousOutput.count) + previousOutput = accumulatedOutput + + if !newContent.isEmpty { + if Date().timeIntervalSince(updateTime) > 60 * 2 { + continuation.yield(.startNewMessage) + continuation.yield(.startAction( + id: "run", + task: "Continue `\(request.text)`" + )) + continuation.yield(.finishAction( + id: "run", + result: .success("Executed.") + )) + continuation.yield(.content(.text("[continue]\n"))) + updateTime = Date() + } + + continuation.yield(.content(.text(String(newContent)))) + } + } + + continuation.finish() + } + + continuation.onTermination = { _ in + task.cancel() + } + } + } } diff --git a/Copilot for Xcode.xcodeproj/project.pbxproj b/Copilot for Xcode.xcodeproj/project.pbxproj index d5f6f3f2..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; @@ -958,13 +960,14 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "Copilot-for-Xcode-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = ""; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( "$(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; @@ -991,13 +994,14 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "Copilot-for-Xcode-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = ""; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( "$(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)"; @@ -1013,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; @@ -1027,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; @@ -1057,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)"; @@ -1090,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)"; @@ -1110,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; @@ -1129,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.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved index a0493266..87fd4d4e 100644 --- a/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,23 @@ { "pins" : [ + { + "identity" : "aexml", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tadija/AEXML.git", + "state" : { + "revision" : "db806756c989760b35108146381535aec231092b", + "version" : "4.7.0" + } + }, + { + "identity" : "cgeventoverride", + "kind" : "remoteSourceControl", + "location" : "https://github.com/intitni/CGEventOverride", + "state" : { + "revision" : "571d36d63e68fac30e4a350600cd186697936f74", + "version" : "1.2.3" + } + }, { "identity" : "codablewrappers", "kind" : "remoteSourceControl", @@ -14,8 +32,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/combine-schedulers", "state" : { - "revision" : "0625932976b3ae23949f6b816d13bd97f3b40b7c", - "version" : "0.10.0" + "revision" : "5928286acce13def418ec36d05a001a9641086f2", + "version" : "1.0.3" + } + }, + { + "identity" : "copilotforxcodekit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/intitni/CopilotForXcodeKit", + "state" : { + "branch" : "feature/custom-chat-tab", + "revision" : "63915ee1f8aba5375bc0f0166c8645fe81fe5b88" } }, { @@ -30,10 +57,10 @@ { "identity" : "generative-ai-swift", "kind" : "remoteSourceControl", - "location" : "https://github.com/google/generative-ai-swift", + "location" : "https://github.com/intitni/generative-ai-swift", "state" : { - "revision" : "f4a88085d5a6c1108f5a1aead83d19d02df8328d", - "version" : "0.4.9" + "branch" : "support-setting-base-url", + "revision" : "12d7b30b566a64cc0dd628130bfb99a07368fea7" } }, { @@ -50,8 +77,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/intitni/Highlightr", "state" : { - "branch" : "bump-highlight-js-version", - "revision" : "4ffbb1b0b721378263297cafea6f2838044eb1eb" + "branch" : "master", + "revision" : "81d8c8b3733939bf5d9e52cd6318f944cc033bd2" + } + }, + { + "identity" : "indexstore-db", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/indexstore-db.git", + "state" : { + "branch" : "release/6.1", + "revision" : "54212fce1aecb199070808bdb265e7f17e396015" } }, { @@ -90,6 +126,24 @@ "version" : "0.8.0" } }, + { + "identity" : "messagepacker", + "kind" : "remoteSourceControl", + "location" : "https://github.com/hirotakan/MessagePacker.git", + "state" : { + "revision" : "4d8346c6bc579347e4df0429493760691c5aeca2", + "version" : "0.4.7" + } + }, + { + "identity" : "networkimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/NetworkImage", + "state" : { + "revision" : "2849f5323265386e200484b0d0f896e73c3411b9", + "version" : "6.0.1" + } + }, { "identity" : "operationplus", "kind" : "remoteSourceControl", @@ -99,6 +153,15 @@ "version" : "1.6.0" } }, + { + "identity" : "pathkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kylef/PathKit.git", + "state" : { + "revision" : "3bfd2737b700b9a36565a8c94f4ad2b050a5e574", + "version" : "1.0.1" + } + }, { "identity" : "processenv", "kind" : "remoteSourceControl", @@ -109,30 +172,30 @@ } }, { - "identity" : "sparkle", + "identity" : "sourcekitten", "kind" : "remoteSourceControl", - "location" : "https://github.com/sparkle-project/Sparkle", + "location" : "https://github.com/jpsim/SourceKitten", "state" : { - "revision" : "87e4fcbac39912f9cdb9a9acf205cad60e1ca3bc", - "version" : "2.4.2" + "revision" : "eb6656ed26bdef967ad8d07c27e2eab34dc582f2", + "version" : "0.37.0" } }, { - "identity" : "sttextkitplus", + "identity" : "sparkle", "kind" : "remoteSourceControl", - "location" : "https://github.com/krzyzanowskim/STTextKitPlus", + "location" : "https://github.com/sparkle-project/Sparkle", "state" : { - "revision" : "a57a2081e364c71b11e521ed8800481e8da300ac", - "version" : "0.1.0" + "revision" : "0ca3004e98712ea2b39dd881d28448630cce1c99", + "version" : "2.7.0" } }, { - "identity" : "sttextview", + "identity" : "spectre", "kind" : "remoteSourceControl", - "location" : "https://github.com/krzyzanowskim/STTextView", + "location" : "https://github.com/kylef/Spectre.git", "state" : { - "revision" : "e9e54718b882115db69ec1e17ac1bec844906cd9", - "version" : "0.9.0" + "revision" : "26cc5e9ae0947092c7139ef7ba612e34646086c7", + "version" : "0.10.1" } }, { @@ -149,8 +212,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-async-algorithms", "state" : { - "revision" : "9cfed92b026c524674ed869a4ff2dcfdeedf8a2a", - "version" : "0.1.0" + "revision" : "042e1c4d9d19748c9c228f8d4ebc97bb1e339b0b", + "version" : "1.0.4" } }, { @@ -158,8 +221,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-case-paths", "state" : { - "revision" : "fc45e7b2cfece9dd80b5a45e6469ffe67fe67984", - "version" : "0.14.1" + "revision" : "41b89b8b68d8c56c622dbb7132258f1a3e638b25", + "version" : "1.7.0" } }, { @@ -167,8 +230,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-clocks", "state" : { - "revision" : "f9acfa1a45f4483fe0f2c434a74e6f68f865d12d", - "version" : "0.3.0" + "revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e", + "version" : "1.0.6" + } + }, + { + "identity" : "swift-cmark", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-cmark", + "state" : { + "revision" : "b022b08312decdc46585e0b3440d97f6f22ef703", + "version" : "0.6.0" } }, { @@ -176,8 +248,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections", "state" : { - "revision" : "937e904258d22af6e447a0b72c0bc67583ef64a2", - "version" : "1.0.4" + "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", + "version" : "1.1.4" } }, { @@ -185,8 +257,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-composable-architecture", "state" : { - "revision" : "89f80fe2400d21a853abc9556a060a2fa50eb2cb", - "version" : "0.55.0" + "revision" : "69247baf7be2fd6f5820192caef0082d01849cd0", + "version" : "1.16.1" + } + }, + { + "identity" : "swift-concurrency-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras", + "state" : { + "revision" : "82a4ae7170d98d8538ec77238b7eb8e7199ef2e8", + "version" : "1.3.1" } }, { @@ -194,8 +275,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-custom-dump", "state" : { - "revision" : "3a35f7892e7cf6ba28a78cd46a703c0be4e0c6dc", - "version" : "0.11.0" + "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1", + "version" : "1.3.3" } }, { @@ -203,8 +284,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "de1a984a71e51f6e488e98ce3652035563eb8acb", - "version" : "0.5.1" + "revision" : "4c90d6b2b9bf0911af87b103bb40f41771891596", + "version" : "1.9.2" } }, { @@ -212,8 +293,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-identified-collections", "state" : { - "revision" : "d01446a78fb768adc9a78cbb6df07767c8ccfc29", - "version" : "0.8.0" + "revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597", + "version" : "1.1.1" } }, { @@ -221,8 +302,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/gonzalezreal/swift-markdown-ui", "state" : { - "revision" : "12b351a75201a8124c2f2e1f9fc6ef5cd812c0b9", - "version" : "2.1.0" + "revision" : "5f613358148239d0292c0cef674a3c2314737f9e", + "version" : "2.4.1" + } + }, + { + "identity" : "swift-navigation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-navigation", + "state" : { + "revision" : "db6bc9dbfed001f21e6728fd36413d9342c235b4", + "version" : "2.3.0" } }, { @@ -230,8 +320,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-parsing", "state" : { - "revision" : "27c941bbd22a4bbc53005a15a0440443fd892f70", - "version" : "0.12.1" + "revision" : "3432cb81164dd3d69a75d0d63205be5fbae2c34b", + "version" : "0.14.1" + } + }, + { + "identity" : "swift-perception", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-perception", + "state" : { + "revision" : "d924c62a70fca5f43872f286dbd7cef0957f1c01", + "version" : "1.6.0" } }, { @@ -239,8 +338,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-syntax.git", "state" : { - "revision" : "6ad4ea24b01559dde0773e3d091f1b9e36175036", - "version" : "509.0.2" + "revision" : "0687f71944021d616d34d922343dcef086855920", + "version" : "600.0.1" } }, { @@ -252,6 +351,15 @@ "version" : "2.6.1" } }, + { + "identity" : "swiftterm", + "kind" : "remoteSourceControl", + "location" : "https://github.com/migueldeicaza/SwiftTerm", + "state" : { + "revision" : "e2b431dbf73f775fb4807a33e4572ffd3dc6933a", + "version" : "1.2.5" + } + }, { "identity" : "swifttreesitter", "kind" : "remoteSourceControl", @@ -262,12 +370,21 @@ } }, { - "identity" : "swiftui-navigation", + "identity" : "swiftui-introspect", "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swiftui-navigation", + "location" : "https://github.com/siteline/swiftui-introspect", "state" : { - "revision" : "2aa885e719087ee19df251c08a5980ad3e787f12", - "version" : "0.8.0" + "revision" : "807f73ce09a9b9723f12385e592b4e0aaebd3336", + "version" : "1.3.0" + } + }, + { + "identity" : "swxmlhash", + "kind" : "remoteSourceControl", + "location" : "https://github.com/drmohundro/SWXMLHash.git", + "state" : { + "revision" : "a853604c9e9a83ad9954c7e3d2a565273982471f", + "version" : "7.0.2" } }, { @@ -297,13 +414,31 @@ "version" : "0.19.3" } }, + { + "identity" : "xcodeproj", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tuist/XcodeProj.git", + "state" : { + "revision" : "b1caa062d4aaab3e3d2bed5fe0ac5f8ce9bf84f4", + "version" : "8.27.7" + } + }, { "identity" : "xctest-dynamic-overlay", "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "50843cbb8551db836adec2290bb4bc6bac5c1865", - "version" : "0.9.0" + "revision" : "39de59b2d47f7ef3ca88a039dff3084688fe27f4", + "version" : "1.5.2" + } + }, + { + "identity" : "yams", + "kind" : "remoteSourceControl", + "location" : "https://github.com/jpsim/Yams.git", + "state" : { + "revision" : "b4b8042411dc7bbb696300a34a4bf3ba1b7ad19b", + "version" : "5.3.1" } } ], 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/Copilot-for-Xcode-Info.plist b/Copilot-for-Xcode-Info.plist index 07a19a85..9f9fdd6e 100644 --- a/Copilot-for-Xcode-Info.plist +++ b/Copilot-for-Xcode-Info.plist @@ -12,6 +12,11 @@ $(EXTENSION_BUNDLE_NAME) HOST_APP_NAME $(HOST_APP_NAME) + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + SUEnableJavaScript YES SUFeedURL diff --git a/Core/Package.swift b/Core/Package.swift index 3934bbbc..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,9 +38,10 @@ 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.0.0"), + .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.6.4"), .package(url: "https://github.com/pointfreeco/swift-parsing", from: "0.12.1"), .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.0.0"), .package( @@ -64,8 +65,8 @@ let package = Package( .product(name: "SuggestionBasic", package: "Tool"), .product(name: "Logger", package: "Tool"), .product(name: "Preferences", package: "Tool"), - ].pro([ - "ProClient", + ].proCore([ + "LicenseManagement", ]) ), .target( @@ -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"), @@ -126,6 +128,7 @@ let package = Package( .product(name: "Toast", package: "Tool"), .product(name: "SharedUIComponents", package: "Tool"), .product(name: "SuggestionBasic", package: "Tool"), + .product(name: "WebSearchService", package: "Tool"), .product(name: "MarkdownUI", package: "swift-markdown-ui"), .product(name: "OpenAIService", package: "Tool"), .product(name: "Preferences", package: "Tool"), @@ -334,12 +337,20 @@ extension [Target.Dependency] { } return self } + + func proCore(_ targetNames: [String]) -> [Target.Dependency] { + if isProIncluded { + return self + targetNames + .map { Target.Dependency.product(name: $0, package: "ProCore") } + } + return self + } } extension [Package.Dependency] { var pro: [Package.Dependency] { if isProIncluded { - return self + [.package(path: "../../Pro")] + return self + [.package(path: "../../Pro"), .package(path: "../../Pro/ProCore")] } return self } 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 56719f5c..60a5504e 100644 --- a/Core/Sources/ChatContextCollectors/WebChatContextCollector/SearchFunction.swift +++ b/Core/Sources/ChatContextCollectors/WebChatContextCollector/SearchFunction.swift @@ -1,8 +1,8 @@ -import BingSearchService import ChatBasic import Foundation import OpenAIService import Preferences +import WebSearchService struct SearchFunction: ChatGPTFunction { static let dateFormatter = { @@ -17,17 +17,21 @@ struct SearchFunction: ChatGPTFunction { } struct Result: ChatGPTFunctionResult { - var result: BingSearchResult + var result: WebSearchResult var botReadableContent: String { - result.webPages.value.enumerated().map { + result.webPages.enumerated().map { let (index, page) = $0 return """ - \(index + 1). \(page.name) \(page.url) + \(index + 1). \(page.title) \(page.urlString) \(page.snippet) """ }.joined(separator: "\n") } + + var userReadableContent: ChatGPTFunctionResultUserReadableContent { + .text(botReadableContent) + } } let maxTokens: Int @@ -72,22 +76,15 @@ struct SearchFunction: ChatGPTFunction { await reportProgress("Searching \(arguments.query)") do { - let bingSearch = BingSearchService( - subscriptionKey: UserDefaults.shared.value(for: \.bingSearchSubscriptionKey), - searchURL: UserDefaults.shared.value(for: \.bingSearchEndpoint) - ) + let search = WebSearchService(provider: .userPreferred) - let result = try await bingSearch.search( - query: arguments.query, - numberOfResult: maxTokens > 5000 ? 5 : 3, - freshness: arguments.freshness - ) + let result = try await search.search(query: arguments.query) await reportProgress(""" Finish searching \(arguments.query) \( - result.webPages.value - .map { "- [\($0.name)](\($0.url))" } + result.webPages + .map { "- [\($0.title)](\($0.urlString))" } .joined(separator: "\n") ) """) diff --git a/Core/Sources/ChatGPTChatTab/Chat.swift b/Core/Sources/ChatGPTChatTab/Chat.swift index 9c89d81a..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() @@ -206,15 +205,15 @@ struct Chat { "/bin/bash", arguments: [ "-c", - "xed -l \(reference.startLine ?? 0) \"\(reference.uri)\"", + "xed -l \(reference.startLine ?? 0) ${TARGET_FILE}", ], - environment: [:] + environment: ["TARGET_FILE": reference.uri] ) } catch { 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/ChatContextMenu.swift b/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift index d4291d2f..9114a5dd 100644 --- a/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift +++ b/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift @@ -72,7 +72,7 @@ struct ChatContextMenu: View { var chatModel: some View { let allModels = chatModels + [.init( id: "com.github.copilot", - name: "GitHub Copilot as chat model", + name: "GitHub Copilot Language Server", format: .openAI, info: .init() )] 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/ChatGPTChatTab/Views/ThemedMarkdownText.swift b/Core/Sources/ChatGPTChatTab/Views/ThemedMarkdownText.swift index 1efa86f5..2811e4ad 100644 --- a/Core/Sources/ChatGPTChatTab/Views/ThemedMarkdownText.swift +++ b/Core/Sources/ChatGPTChatTab/Views/ThemedMarkdownText.swift @@ -71,7 +71,10 @@ extension MarkdownUI.Theme { } .codeBlock { configuration in let wrapCode = UserDefaults.shared.value(for: \.wrapCodeInChatCodeBlock) - || ["plaintext", "text", "markdown", "sh", "bash", "shell", "latex", "tex"] + || [ + "plaintext", "text", "markdown", "sh", "console", "bash", "shell", "latex", + "tex" + ] .contains(configuration.language) if wrapCode { diff --git a/Core/Sources/ChatService/AllPlugins.swift b/Core/Sources/ChatService/AllPlugins.swift index be6c9846..3f0b9de7 100644 --- a/Core/Sources/ChatService/AllPlugins.swift +++ b/Core/Sources/ChatService/AllPlugins.swift @@ -56,7 +56,7 @@ final class LegacyChatPluginWrapper: LegacyChatPlugin { let plugin = Plugin() - let stream = await plugin.send(.init( + let stream = await plugin.sendForComplicatedResponse(.init( text: content, arguments: [], history: chatGPTService.memory.history @@ -94,6 +94,8 @@ final class LegacyChatPluginWrapper: LegacyChatPlugin { break case .startNewMessage: break + case .reasoning: + break } await chatGPTService.memory.mutateHistory { history in diff --git a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManagementView.swift b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManagementView.swift index 8c06d2dc..bc6c910e 100644 --- a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManagementView.swift +++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManagementView.swift @@ -82,8 +82,10 @@ struct APIKeyManagementView: View { state: \.apiKeySubmission, action: \.apiKeySubmission )) { store in - APIKeySubmissionView(store: store) - .frame(minWidth: 400) + WithPerceptionTracking { + APIKeySubmissionView(store: store) + .frame(minWidth: 400) + } } } } diff --git a/Core/Sources/HostApp/AccountSettings/BingSearchView.swift b/Core/Sources/HostApp/AccountSettings/BingSearchView.swift deleted file mode 100644 index 7504e828..00000000 --- a/Core/Sources/HostApp/AccountSettings/BingSearchView.swift +++ /dev/null @@ -1,51 +0,0 @@ -import AppKit -import Client -import OpenAIService -import Preferences -import SuggestionBasic -import SwiftUI - -final class BingSearchViewSettings: ObservableObject { - @AppStorage(\.bingSearchSubscriptionKey) var bingSearchSubscriptionKey: String - @AppStorage(\.bingSearchEndpoint) var bingSearchEndpoint: String - init() {} -} - -struct BingSearchView: View { - @Environment(\.openURL) var openURL - @StateObject var settings = BingSearchViewSettings() - - var body: some View { - Form { - Button(action: { - let url = URL(string: "https://www.microsoft.com/bing/apis/bing-web-search-api")! - openURL(url) - }) { - Text("Apply for Subscription Key for Free") - } - - SecureField(text: $settings.bingSearchSubscriptionKey, prompt: Text("")) { - Text("Bing Search Subscription Key") - } - .textFieldStyle(.roundedBorder) - - TextField( - text: $settings.bingSearchEndpoint, - prompt: Text("https://api.bing.microsoft.com/***") - ) { - Text("Bing Search Endpoint") - }.textFieldStyle(.roundedBorder) - } - } -} - -struct BingSearchView_Previews: PreviewProvider { - static var previews: some View { - VStack(alignment: .leading, spacing: 8) { - BingSearchView() - } - .frame(height: 800) - .padding(.all, 8) - } -} - diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift index f1407c31..f0c673e5 100644 --- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift @@ -33,6 +33,9 @@ struct ChatModelEdit { var openAIProjectID: String = "" var customHeaders: [ChatModel.Info.CustomHeaderInfo.HeaderField] = [] var openAICompatibleSupportsMultipartMessageContent = true + var requiresBeginWithUserMessage = false + var customBody: String = "" + var supportsImages: Bool = true } enum Action: Equatable, BindableAction { @@ -45,10 +48,44 @@ struct ChatModelEdit { case testSucceeded(String) case testFailed(String) case checkSuggestedMaxTokens + case selectModelFormat(ModelFormat) case apiKeySelection(APIKeySelection.Action) case baseURLSelection(BaseURLSelection.Action) } + enum ModelFormat: CaseIterable { + case openAI + case azureOpenAI + case googleAI + case ollama + case claude + case gitHubCopilot + case openAICompatible + case deepSeekOpenAICompatible + case openRouterOpenAICompatible + case grokOpenAICompatible + case mistralOpenAICompatible + + init(_ format: ChatModel.Format) { + switch format { + case .openAI: + self = .openAI + case .azureOpenAI: + self = .azureOpenAI + case .googleAI: + self = .googleAI + case .ollama: + self = .ollama + case .claude: + self = .claude + case .openAICompatible: + self = .openAICompatible + case .gitHubCopilot: + self = .gitHubCopilot + } + } + } + var toast: (String, ToastType) -> Void { @Dependency(\.namespacedToast) var toast return { @@ -164,11 +201,53 @@ struct ChatModelEdit { state.suggestedMaxTokens = nil } return .none + case .gitHubCopilot: + if let knownModel = AvailableGitHubCopilotModel(rawValue: state.modelName) { + state.suggestedMaxTokens = knownModel.contextWindow + } else { + state.suggestedMaxTokens = nil + } + return .none default: state.suggestedMaxTokens = nil return .none } + case let .selectModelFormat(format): + switch format { + case .openAI: + state.format = .openAI + case .azureOpenAI: + state.format = .azureOpenAI + case .googleAI: + state.format = .googleAI + case .ollama: + state.format = .ollama + case .claude: + state.format = .claude + case .gitHubCopilot: + state.format = .gitHubCopilot + case .openAICompatible: + state.format = .openAICompatible + case .deepSeekOpenAICompatible: + state.format = .openAICompatible + state.baseURLSelection.baseURL = "https://api.deepseek.com" + state.baseURLSelection.isFullURL = false + case .openRouterOpenAICompatible: + state.format = .openAICompatible + state.baseURLSelection.baseURL = "https://openrouter.ai" + state.baseURLSelection.isFullURL = false + case .grokOpenAICompatible: + state.format = .openAICompatible + state.baseURLSelection.baseURL = "https://api.x.ai" + state.baseURLSelection.isFullURL = false + case .mistralOpenAICompatible: + state.format = .openAICompatible + state.baseURLSelection.baseURL = "https://api.mistral.ai" + state.baseURLSelection.isFullURL = false + } + return .none + case .apiKeySelection: return .none @@ -208,11 +287,13 @@ extension ChatModel { switch state.format { case .googleAI, .ollama, .claude: return false - case .azureOpenAI, .openAI, .openAICompatible: + case .azureOpenAI, .openAI, .openAICompatible, .gitHubCopilot: return state.supportsFunctionCalling } }(), - modelName: state.modelName.trimmingCharacters(in: .whitespacesAndNewlines), + supportsImage: state.supportsImages, + modelName: state.modelName + .trimmingCharacters(in: .whitespacesAndNewlines), openAIInfo: .init( organizationID: state.openAIOrganizationID, projectID: state.openAIProjectID @@ -222,9 +303,11 @@ extension ChatModel { openAICompatibleInfo: .init( enforceMessageOrder: state.enforceMessageOrder, supportsMultipartMessageContent: state - .openAICompatibleSupportsMultipartMessageContent + .openAICompatibleSupportsMultipartMessageContent, + requiresBeginWithUserMessage: state.requiresBeginWithUserMessage ), - customHeaderInfo: .init(headers: state.customHeaders) + customHeaderInfo: .init(headers: state.customHeaders), + customBodyInfo: .init(jsonBody: state.customBody) ) ) } @@ -249,7 +332,10 @@ extension ChatModel { openAIProjectID: info.openAIInfo.projectID, customHeaders: info.customHeaderInfo.headers, openAICompatibleSupportsMultipartMessageContent: info.openAICompatibleInfo - .supportsMultipartMessageContent + .supportsMultipartMessageContent, + requiresBeginWithUserMessage: info.openAICompatibleInfo.requiresBeginWithUserMessage, + customBody: info.customBodyInfo.jsonBody, + supportsImages: info.supportsImage ) } } diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift index c6b74281..d16b7556 100644 --- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift @@ -29,6 +29,8 @@ struct ChatModelEditView: View { OllamaForm(store: store) case .claude: ClaudeForm(store: store) + case .gitHubCopilot: + GitHubCopilotForm(store: store) } } .padding() @@ -48,6 +50,25 @@ struct ChatModelEditView: View { } } + CustomBodyEdit(store: store) + .disabled({ + switch store.format { + case .openAI, .openAICompatible, .claude: + return false + default: + return true + } + }()) + CustomHeaderEdit(store: store) + .disabled({ + switch store.format { + case .openAI, .openAICompatible, .ollama, .gitHubCopilot, .claude: + return false + default: + return true + } + }()) + Spacer() Button("Cancel") { @@ -86,31 +107,44 @@ struct ChatModelEditView: View { var body: some View { WithPerceptionTracking { Picker( - selection: $store.format, + selection: Binding( + get: { .init(store.format) }, + set: { store.send(.selectModelFormat($0)) } + ), content: { ForEach( - ChatModel.Format.allCases, - id: \.rawValue + ChatModelEdit.ModelFormat.allCases, + id: \.self ) { format in switch format { case .openAI: - Text("OpenAI").tag(format) + Text("OpenAI") case .azureOpenAI: - Text("Azure OpenAI").tag(format) + Text("Azure OpenAI") case .openAICompatible: - Text("OpenAI Compatible").tag(format) + Text("OpenAI Compatible") case .googleAI: - Text("Google Generative AI").tag(format) + Text("Google AI") case .ollama: - Text("Ollama").tag(format) + Text("Ollama") case .claude: - Text("Claude").tag(format) + Text("Claude") + case .gitHubCopilot: + Text("GitHub Copilot") + case .deepSeekOpenAICompatible: + Text("DeepSeek (OpenAI Compatible)") + case .openRouterOpenAICompatible: + Text("OpenRouter (OpenAI Compatible)") + case .grokOpenAICompatible: + Text("Grok (OpenAI Compatible)") + case .mistralOpenAICompatible: + Text("Mistral (OpenAI Compatible)") } } }, label: { Text("Format") } ) - .pickerStyle(.segmented) + .pickerStyle(.menu) } } } @@ -215,6 +249,79 @@ struct ChatModelEditView: View { } } + struct CustomBodyEdit: View { + @Perception.Bindable var store: StoreOf + @State private var isEditing = false + @Dependency(\.namespacedToast) var toast + + var body: some View { + Button("Custom Body") { + isEditing = true + } + .sheet(isPresented: $isEditing) { + WithPerceptionTracking { + VStack { + TextEditor(text: $store.customBody) + .font(Font.system(.body, design: .monospaced)) + .padding(4) + .frame(minHeight: 120) + .multilineTextAlignment(.leading) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + .handleToast(namespace: "CustomBodyEdit") + + Text( + "The custom body will be added to the request body. Please use it to add parameters that are not yet available in the form. It should be a valid JSON object." + ) + .foregroundColor(.secondary) + .font(.callout) + .padding(.bottom) + + Button(action: { + if store.customBody.trimmingCharacters(in: .whitespacesAndNewlines) + .isEmpty + { + isEditing = false + return + } + guard let _ = try? JSONSerialization + .jsonObject(with: store.customBody.data(using: .utf8) ?? Data()) + else { + toast("Invalid JSON object", .error, "CustomBodyEdit") + return + } + isEditing = false + }) { + Text("Done") + } + .keyboardShortcut(.defaultAction) + } + .padding() + .frame(width: 600, height: 500) + .background(Color(nsColor: .windowBackgroundColor)) + } + } + } + } + + struct CustomHeaderEdit: View { + @Perception.Bindable var store: StoreOf + @State private var isEditing = false + + var body: some View { + Button("Custom Headers") { + isEditing = true + } + .sheet(isPresented: $isEditing) { + WithPerceptionTracking { + CustomHeaderSettingsView(headers: $store.customHeaders) + } + } + } + } + struct OpenAIForm: View { @Perception.Bindable var store: StoreOf var body: some View { @@ -243,7 +350,7 @@ struct ChatModelEditView: View { MaxTokensTextField(store: store) SupportsFunctionCallingToggle(store: store) - + TextField(text: $store.openAIOrganizationID, prompt: Text("Optional")) { Text("Organization ID") } @@ -251,6 +358,10 @@ struct ChatModelEditView: View { TextField(text: $store.openAIProjectID, prompt: Text("Optional")) { Text("Project ID") } + + Toggle(isOn: $store.supportsImages) { + Text("Supports Images") + } VStack(alignment: .leading, spacing: 8) { Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( @@ -279,13 +390,16 @@ struct ChatModelEditView: View { MaxTokensTextField(store: store) SupportsFunctionCallingToggle(store: store) + + Toggle(isOn: $store.supportsImages) { + Text("Supports Images") + } } } } struct OpenAICompatibleForm: View { @Perception.Bindable var store: StoreOf - @State var isEditingCustomHeader = false var body: some View { WithPerceptionTracking { @@ -321,16 +435,18 @@ struct ChatModelEditView: View { Toggle(isOn: $store.enforceMessageOrder) { Text("Enforce message order to be user/assistant alternated") } - + Toggle(isOn: $store.openAICompatibleSupportsMultipartMessageContent) { Text("Support multi-part message content") } + + Toggle(isOn: $store.requiresBeginWithUserMessage) { + Text("Requires the first message to be from the user") + } - Button("Custom Headers") { - isEditingCustomHeader.toggle() + Toggle(isOn: $store.supportsImages) { + Text("Supports Images") } - }.sheet(isPresented: $isEditingCustomHeader) { - CustomHeaderSettingsView(headers: $store.customHeaders) } } } @@ -369,18 +485,25 @@ struct ChatModelEditView: View { MaxTokensTextField(store: store) TextField("API Version", text: $store.apiVersion, prompt: Text("v1")) + + Toggle(isOn: $store.supportsImages) { + Text("Supports Images") + } } } } struct OllamaForm: View { @Perception.Bindable var store: StoreOf + var body: some View { WithPerceptionTracking { BaseURLTextField(store: store, prompt: Text("http://127.0.0.1:11434")) { Text("/api/chat") } + ApiKeyNamePicker(store: store) + TextField("Model Name", text: $store.modelName) MaxTokensTextField(store: store) @@ -389,6 +512,10 @@ struct ChatModelEditView: View { Text("Keep Alive") } + Toggle(isOn: $store.supportsImages) { + Text("Supports Images") + } + VStack(alignment: .leading, spacing: 8) { Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( " For more details, please visit [https://ollama.com](https://ollama.com)." @@ -432,6 +559,10 @@ struct ChatModelEditView: View { } MaxTokensTextField(store: store) + + Toggle(isOn: $store.supportsImages) { + Text("Supports Images") + } VStack(alignment: .leading, spacing: 8) { Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( @@ -442,6 +573,48 @@ struct ChatModelEditView: View { } } } + + struct GitHubCopilotForm: View { + @Perception.Bindable var store: StoreOf + + var body: some View { + WithPerceptionTracking { + #warning("Todo: use the old picker and update the context window limit.") + GitHubCopilotModelPicker( + title: "Model Name", + hasDefaultModel: false, + gitHubCopilotModelId: $store.modelName + ) + + MaxTokensTextField(store: store) + SupportsFunctionCallingToggle(store: store) + + Toggle(isOn: $store.enforceMessageOrder) { + Text("Enforce message order to be user/assistant alternated") + } + + Toggle(isOn: $store.openAICompatibleSupportsMultipartMessageContent) { + Text("Support multi-part message content") + } + + Toggle(isOn: $store.supportsImages) { + Text("Supports Images") + } + + VStack(alignment: .leading, spacing: 8) { + Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( + " Please login in the GitHub Copilot settings to use the model." + ) + + Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( + " This will call the APIs directly, which may not be allowed by GitHub. But it's used in other popular apps like Zed." + ) + } + .dynamicHeightTextInFormWorkaround() + .padding(.vertical) + } + } + } } #Preview("OpenAI") { diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagement.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagement.swift index 4dc46630..64eadd57 100644 --- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagement.swift +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagement.swift @@ -13,6 +13,7 @@ extension ChatModel: ManageableAIModel { case .googleAI: return "Google Generative AI" case .ollama: return "Ollama" case .claude: return "Claude" + case .gitHubCopilot: return "GitHub Copilot" } } diff --git a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift index 4e36a583..f057be21 100644 --- a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift +++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift @@ -41,9 +41,35 @@ struct EmbeddingModelEdit { case testFailed(String) case fixDimensions(Int) case checkSuggestedMaxTokens + case selectModelFormat(ModelFormat) case apiKeySelection(APIKeySelection.Action) case baseURLSelection(BaseURLSelection.Action) } + + enum ModelFormat: CaseIterable { + case openAI + case azureOpenAI + case ollama + case gitHubCopilot + case openAICompatible + case mistralOpenAICompatible + case voyageAIOpenAICompatible + + init(_ format: EmbeddingModel.Format) { + switch format { + case .openAI: + self = .openAI + case .azureOpenAI: + self = .azureOpenAI + case .ollama: + self = .ollama + case .openAICompatible: + self = .openAICompatible + case .gitHubCopilot: + self = .gitHubCopilot + } + } + } var toast: (String, ToastType) -> Void { @Dependency(\.namespacedToast) var toast @@ -155,6 +181,29 @@ struct EmbeddingModelEdit { case let .fixDimensions(value): state.dimensions = value return .none + + case let .selectModelFormat(format): + switch format { + case .openAI: + state.format = .openAI + case .azureOpenAI: + state.format = .azureOpenAI + case .ollama: + state.format = .ollama + case .openAICompatible: + state.format = .openAICompatible + case .gitHubCopilot: + state.format = .gitHubCopilot + case .mistralOpenAICompatible: + state.format = .openAICompatible + state.baseURLSelection.baseURL = "https://api.mistral.ai" + state.baseURLSelection.isFullURL = false + case .voyageAIOpenAICompatible: + state.format = .openAICompatible + state.baseURLSelection.baseURL = "https://api.voyage.ai" + state.baseURLSelection.isFullURL = false + } + return .none case .apiKeySelection: return .none diff --git a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift index 45014756..46f4effd 100644 --- a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift +++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift @@ -24,6 +24,8 @@ struct EmbeddingModelEditView: View { OpenAICompatibleForm(store: store) case .ollama: OllamaForm(store: store) + case .gitHubCopilot: + GitHubCopilotForm(store: store) } } .padding() @@ -81,27 +83,36 @@ struct EmbeddingModelEditView: View { var body: some View { WithPerceptionTracking { Picker( - selection: $store.format, + selection: Binding( + get: { .init(store.format) }, + set: { store.send(.selectModelFormat($0)) } + ), content: { ForEach( - EmbeddingModel.Format.allCases, - id: \.rawValue + EmbeddingModelEdit.ModelFormat.allCases, + id: \.self ) { format in switch format { case .openAI: - Text("OpenAI").tag(format) + Text("OpenAI") case .azureOpenAI: - Text("Azure OpenAI").tag(format) - case .openAICompatible: - Text("OpenAI Compatible").tag(format) + Text("Azure OpenAI") case .ollama: - Text("Ollama").tag(format) + Text("Ollama") + case .openAICompatible: + Text("OpenAI Compatible") + case .mistralOpenAICompatible: + Text("Mistral (OpenAI Compatible)") + case .voyageAIOpenAICompatible: + Text("Voyage (OpenAI Compatible)") + case .gitHubCopilot: + Text("GitHub Copilot") } } }, label: { Text("Format") } ) - .pickerStyle(.segmented) + .pickerStyle(.menu) } } } @@ -174,7 +185,7 @@ struct EmbeddingModelEditView: View { } } } - + struct DimensionsTextField: View { @Perception.Bindable var store: StoreOf @@ -212,7 +223,7 @@ struct EmbeddingModelEditView: View { return .primary }() as Color) } - + Text("If you are not sure, run test to get the correct value.") .font(.caption) .dynamicHeightTextInFormWorkaround() @@ -327,7 +338,7 @@ struct EmbeddingModelEditView: View { MaxTokensTextField(store: store) DimensionsTextField(store: store) - + Button("Custom Headers") { isEditingCustomHeader.toggle() } @@ -339,11 +350,16 @@ struct EmbeddingModelEditView: View { struct OllamaForm: View { @Perception.Bindable var store: StoreOf + @State var isEditingCustomHeader = false + var body: some View { WithPerceptionTracking { BaseURLTextField(store: store, prompt: Text("http://127.0.0.1:11434")) { Text("/api/embeddings") } + + ApiKeyNamePicker(store: store) + TextField("Model Name", text: $store.modelName) MaxTokensTextField(store: store) @@ -355,12 +371,66 @@ struct EmbeddingModelEditView: View { } } + Button("Custom Headers") { + isEditingCustomHeader.toggle() + } + VStack(alignment: .leading, spacing: 8) { Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( " For more details, please visit [https://ollama.com](https://ollama.com)." ) } .padding(.vertical) + + }.sheet(isPresented: $isEditingCustomHeader) { + CustomHeaderSettingsView(headers: $store.customHeaders) + } + } + } + + struct GitHubCopilotForm: View { + @Perception.Bindable var store: StoreOf + @State var isEditingCustomHeader = false + + var body: some View { + WithPerceptionTracking { + TextField("Model Name", text: $store.modelName) + .overlay(alignment: .trailing) { + Picker( + "", + selection: $store.modelName, + content: { + if OpenAIEmbeddingModel(rawValue: store.modelName) == nil { + Text("Custom Model").tag(store.modelName) + } + ForEach(OpenAIEmbeddingModel.allCases, id: \.self) { model in + Text(model.rawValue).tag(model.rawValue) + } + } + ) + .frame(width: 20) + } + + MaxTokensTextField(store: store) + DimensionsTextField(store: store) + + Button("Custom Headers") { + isEditingCustomHeader.toggle() + } + + VStack(alignment: .leading, spacing: 8) { + Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( + " Please login in the GitHub Copilot settings to use the model." + ) + + Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( + " This will call the APIs directly, which may not be allowed by GitHub. But it's used in other popular apps like Zed." + ) + } + .dynamicHeightTextInFormWorkaround() + .padding(.vertical) + }.sheet(isPresented: $isEditingCustomHeader) { + CustomHeaderSettingsView(headers: $store.customHeaders) } } } diff --git a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagement.swift b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagement.swift index 294ca401..156f58ac 100644 --- a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagement.swift +++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagement.swift @@ -11,6 +11,7 @@ extension EmbeddingModel: ManageableAIModel { case .azureOpenAI: return "Azure OpenAI" case .openAICompatible: return "OpenAI Compatible" case .ollama: return "Ollama" + case .gitHubCopilot: return "GitHub Copilot" } } diff --git a/Core/Sources/HostApp/AccountSettings/GitHubCopilotModelPicker.swift b/Core/Sources/HostApp/AccountSettings/GitHubCopilotModelPicker.swift new file mode 100644 index 00000000..9f4b0d8d --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/GitHubCopilotModelPicker.swift @@ -0,0 +1,91 @@ +import Dependencies +import Foundation +import GitHubCopilotService +import Perception +import SwiftUI +import Toast + +public struct GitHubCopilotModelPicker: View { + @Perceptible + final class ViewModel { + var availableModels: [GitHubCopilotLLMModel] = [] + @PerceptionIgnored @Dependency(\.toast) var toast + + init() {} + + func appear() { + reloadAvailableModels() + } + + func disappear() {} + + func reloadAvailableModels() { + Task { @MainActor in + do { + availableModels = try await GitHubCopilotExtension.fetchLLMModels() + } catch { + toast("Failed to fetch GitHub Copilot models: \(error)", .error) + } + } + } + } + + let title: String + let hasDefaultModel: Bool + @Binding var gitHubCopilotModelId: String + @State var viewModel: ViewModel + + init( + title: String, + hasDefaultModel: Bool = true, + gitHubCopilotModelId: Binding + ) { + self.title = title + _gitHubCopilotModelId = gitHubCopilotModelId + self.hasDefaultModel = hasDefaultModel + viewModel = .init() + } + + public var body: some View { + WithPerceptionTracking { + TextField(title, text: $gitHubCopilotModelId) + .overlay(alignment: .trailing) { + Picker( + "", + selection: $gitHubCopilotModelId, + content: { + if hasDefaultModel { + Text("Default").tag("") + } + + if !gitHubCopilotModelId.isEmpty, + !viewModel.availableModels.contains(where: { + $0.modelId == gitHubCopilotModelId + }) + { + Text(gitHubCopilotModelId).tag(gitHubCopilotModelId) + } + if viewModel.availableModels.isEmpty { + Text({ + viewModel.reloadAvailableModels() + return "Loading..." + }()).tag("Loading...") + } + ForEach(viewModel.availableModels) { model in + Text(model.modelId) + .tag(model.modelId) + } + } + ) + .frame(width: 20) + } + .onAppear { + viewModel.appear() + } + .onDisappear { + viewModel.disappear() + } + } + } +} + diff --git a/Core/Sources/HostApp/AccountSettings/GitHubCopilotView.swift b/Core/Sources/HostApp/AccountSettings/GitHubCopilotView.swift index 8fd049c1..ec627113 100644 --- a/Core/Sources/HostApp/AccountSettings/GitHubCopilotView.swift +++ b/Core/Sources/HostApp/AccountSettings/GitHubCopilotView.swift @@ -25,6 +25,7 @@ struct GitHubCopilotView: View { var disableGitHubCopilotSettingsAutoRefreshOnAppear @AppStorage(\.gitHubCopilotLoadKeyChainCertificates) var gitHubCopilotLoadKeyChainCertificates + @AppStorage(\.gitHubCopilotModelId) var gitHubCopilotModelId init() {} } @@ -157,7 +158,7 @@ struct GitHubCopilotView: View { "node" ) ) { - Text("Path to Node (v18+)") + Text("Path to Node (v22.0+)") } Text( @@ -199,7 +200,7 @@ struct GitHubCopilotView: View { .foregroundColor(.secondary) .font(.callout) .dynamicHeightTextInFormWorkaround() - + Toggle(isOn: $settings.gitHubCopilotLoadKeyChainCertificates) { Text("Load certificates in keychain") } @@ -260,13 +261,20 @@ struct GitHubCopilotView: View { if isRunningAction { ActivityIndicatorView() } - } + } .opacity(isRunningAction ? 0.8 : 1) .disabled(isRunningAction) Button("Refresh configurations") { refreshConfiguration() } + + Form { + GitHubCopilotModelPicker( + title: "Chat Model Name", + gitHubCopilotModelId: $settings.gitHubCopilotModelId + ) + } } SettingsDivider("Advanced") @@ -349,7 +357,6 @@ struct GitHubCopilotView: View { if status != .ok, status != .notSignedIn { toast( "GitHub Copilot status is not \"ok\". Please check if you have a valid GitHub Copilot subscription.", - .error ) } diff --git a/Core/Sources/HostApp/AccountSettings/WebSearchView.swift b/Core/Sources/HostApp/AccountSettings/WebSearchView.swift new file mode 100644 index 00000000..d34686f9 --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/WebSearchView.swift @@ -0,0 +1,277 @@ +import AppKit +import Client +import ComposableArchitecture +import OpenAIService +import Preferences +import SuggestionBasic +import SwiftUI +import WebSearchService +import SharedUIComponents + +@Reducer +struct WebSearchSettings { + struct TestResult: Identifiable, Equatable { + let id = UUID() + var duration: TimeInterval + var result: Result? + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.id == rhs.id + } + } + + @ObservableState + struct State: Equatable { + var apiKeySelection: APIKeySelection.State = .init() + var testResult: TestResult? + } + + enum Action: BindableAction { + case binding(BindingAction) + case appear + case test + case bringUpTestResult + case updateTestResult(TimeInterval, Result) + case apiKeySelection(APIKeySelection.Action) + } + + var body: some ReducerOf { + BindingReducer() + + Scope(state: \.apiKeySelection, action: \.apiKeySelection) { + APIKeySelection() + } + + Reduce { state, action in + switch action { + case .binding: + return .none + case .appear: + state.testResult = nil + state.apiKeySelection.apiKeyName = UserDefaults.shared.value(for: \.serpAPIKeyName) + return .none + case .test: + return .run { send in + let searchService = WebSearchService(provider: .userPreferred) + await send(.bringUpTestResult) + let start = Date() + do { + let result = try await searchService.search(query: "Swift") + let duration = Date().timeIntervalSince(start) + await send(.updateTestResult(duration, .success(result))) + } catch { + let duration = Date().timeIntervalSince(start) + await send(.updateTestResult(duration, .failure(error))) + } + } + case .bringUpTestResult: + state.testResult = .init(duration: 0) + return .none + case let .updateTestResult(duration, result): + state.testResult?.duration = duration + state.testResult?.result = result + return .none + case let .apiKeySelection(action): + switch action { + case .binding(\APIKeySelection.State.apiKeyName): + UserDefaults.shared.set(state.apiKeySelection.apiKeyName, for: \.serpAPIKeyName) + return .none + default: + return .none + } + } + } + } +} + +final class WebSearchViewSettings: ObservableObject { + @AppStorage(\.serpAPIEngine) var serpAPIEngine + @AppStorage(\.headlessBrowserEngine) var headlessBrowserEngine + @AppStorage(\.searchProvider) var searchProvider + init() {} +} + +struct WebSearchView: View { + @Perception.Bindable var store: StoreOf + @Environment(\.openURL) var openURL + @StateObject var settings = WebSearchViewSettings() + + var body: some View { + WithPerceptionTracking { + ScrollView { + VStack(alignment: .leading) { + Form { + Picker("Search Provider", selection: $settings.searchProvider) { + ForEach(UserDefaultPreferenceKeys.SearchProvider.allCases, id: \.self) { + provider in + switch provider { + case .serpAPI: + Text("Serp API").tag(provider) + case .headlessBrowser: + Text("Headless Browser").tag(provider) + } + + } + } + .pickerStyle(.segmented) + } + + switch settings.searchProvider { + case .serpAPI: + serpAPIForm() + case .headlessBrowser: + headlessBrowserForm() + } + } + .padding() + } + .safeAreaInset(edge: .bottom) { + VStack(spacing: 0) { + Divider() + HStack { + Button("Test Search") { + store.send(.test) + } + Spacer() + } + .padding() + } + .background(.regularMaterial) + } + .sheet(item: $store.testResult) { testResult in + testResultView(testResult: testResult) + } + .onAppear { + store.send(.appear) + } + } + } + + @ViewBuilder + func serpAPIForm() -> some View { + SubSection( + title: Text("Serp API Settings"), + description: """ + Use Serp API to do web search. Serp API is more reliable and faster than headless browser. But you need to provide an API key for it. + """ + ) { + Picker("Engine", selection: $settings.serpAPIEngine) { + ForEach( + UserDefaultPreferenceKeys.SerpAPIEngine.allCases, + id: \.self + ) { engine in + Text(engine.rawValue).tag(engine) + } + } + + WithPerceptionTracking { + APIKeyPicker(store: store.scope( + state: \.apiKeySelection, + action: \.apiKeySelection + )) + } + } + } + + @ViewBuilder + func headlessBrowserForm() -> some View { + SubSection( + title: Text("Headless Browser Settings"), + description: """ + The app will open a webview in the background to do web search. This method uses a set of rules to extract information from the web page, if you notice that it stops working, please submit an issue to the developer. + """ + ) { + Picker("Engine", selection: $settings.headlessBrowserEngine) { + ForEach( + UserDefaultPreferenceKeys.HeadlessBrowserEngine.allCases, + id: \.self + ) { engine in + Text(engine.rawValue).tag(engine) + } + } + } + } + + @ViewBuilder + func testResultView(testResult: WebSearchSettings.TestResult) -> some View { + VStack { + Text("Test Result") + .padding(.top) + .font(.headline) + + if let result = testResult.result { + switch result { + case let .success(webSearchResult): + VStack(alignment: .leading) { + Text("Success (Completed in \(testResult.duration, specifier: "%.2f")s)") + .foregroundColor(.green) + + Text("Found \(webSearchResult.webPages.count) results:") + + ScrollView { + ForEach(webSearchResult.webPages, id: \.urlString) { page in + HStack { + VStack(alignment: .leading) { + Text(page.title) + .font(.headline) + Text(page.urlString) + .font(.caption) + .foregroundColor(.blue) + Text(page.snippet) + .padding(.top, 2) + } + Spacer(minLength: 0) + } + .padding(.vertical, 4) + Divider() + } + } + } + .padding() + case let .failure(error): + VStack(alignment: .leading) { + Text("Error (Completed in \(testResult.duration, specifier: "%.2f")s)") + .foregroundColor(.red) + Text(error.localizedDescription) + } + } + } else { + ProgressView().padding() + } + + Spacer() + + VStack(spacing: 0) { + Divider() + + HStack { + Spacer() + + Button("Close") { + store.testResult = nil + } + .keyboardShortcut(.cancelAction) + } + .padding() + } + } + .frame(minWidth: 400, minHeight: 300) + } +} + +// Helper struct to make TestResult identifiable for sheet presentation +private struct TestResultWrapper: Identifiable { + var id: UUID = .init() + var testResult: WebSearchSettings.TestResult +} + +struct WebSearchView_Previews: PreviewProvider { + static var previews: some View { + VStack(alignment: .leading, spacing: 8) { + WebSearchView(store: .init(initialState: .init(), reducer: { WebSearchSettings() })) + } + .frame(height: 800) + .padding(.all, 8) + } +} + 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/CustomCommandSettings/EditCustomCommandView.swift b/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift index d42cac83..e2304f8b 100644 --- a/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift +++ b/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift @@ -263,7 +263,7 @@ extension CustomCommand.Attachment.Kind { case .senseScope: return "Sense Scope" case .projectScope: return "Project Scope" case .webScope: return "Web Scope" - case .gitStatus: return "Git Status" + case .gitStatus: return "Git Status and Diff" case .gitLog: return "Git Log" case .file: return "File" } diff --git a/Core/Sources/HostApp/FeatureSettings/Chat/ChatSettingsGeneralSectionView.swift b/Core/Sources/HostApp/FeatureSettings/Chat/ChatSettingsGeneralSectionView.swift index b78d00a4..dfb60355 100644 --- a/Core/Sources/HostApp/FeatureSettings/Chat/ChatSettingsGeneralSectionView.swift +++ b/Core/Sources/HostApp/FeatureSettings/Chat/ChatSettingsGeneralSectionView.swift @@ -25,6 +25,7 @@ struct ChatSettingsGeneralSectionView: View { @AppStorage(\.chatModels) var chatModels @AppStorage(\.embeddingModels) var embeddingModels @AppStorage(\.wrapCodeInChatCodeBlock) var wrapCodeInCodeBlock + @AppStorage(\.chatPanelFloatOnTopOption) var chatPanelFloatOnTopOption @AppStorage( \.keepFloatOnTopIfChatPanelAndXcodeOverlaps ) var keepFloatOnTopIfChatPanelAndXcodeOverlaps @@ -155,7 +156,7 @@ struct ChatSettingsGeneralSectionView: View { ) { let allModels = settings.chatModels + [.init( id: "com.github.copilot", - name: "GitHub Copilot as chat model", + name: "GitHub Copilot Language Server", format: .openAI, info: .init() )] @@ -298,13 +299,24 @@ struct ChatSettingsGeneralSectionView: View { CodeHighlightThemePicker(scenario: .chat) + Picker("Always-on-top behavior", selection: $settings.chatPanelFloatOnTopOption) { + Text("Always").tag(UserDefaultPreferenceKeys.ChatPanelFloatOnTopOption.alwaysOnTop) + Text("When Xcode is active") + .tag(UserDefaultPreferenceKeys.ChatPanelFloatOnTopOption.onTopWhenXcodeIsActive) + Text("Never").tag(UserDefaultPreferenceKeys.ChatPanelFloatOnTopOption.never) + } + Toggle(isOn: $settings.disableFloatOnTopWhenTheChatPanelIsDetached) { Text("Disable always-on-top when the chat panel is detached") - } + }.disabled(settings.chatPanelFloatOnTopOption == .never) Toggle(isOn: $settings.keepFloatOnTopIfChatPanelAndXcodeOverlaps) { Text("Keep always-on-top if the chat panel and Xcode overlaps and Xcode is active") - }.disabled(!settings.disableFloatOnTopWhenTheChatPanelIsDetached) + } + .disabled( + !settings.disableFloatOnTopWhenTheChatPanelIsDetached + || settings.chatPanelFloatOnTopOption == .never + ) } } diff --git a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureDisabledLanguageListView.swift b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureDisabledLanguageListView.swift index 41fa9fb4..6d894cfd 100644 --- a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureDisabledLanguageListView.swift +++ b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureDisabledLanguageListView.swift @@ -74,7 +74,7 @@ struct SuggestionFeatureDisabledLanguageListView: View { if settings.suggestionFeatureDisabledLanguageList.isEmpty { Text(""" Empty - Disable the language of a file by right clicking the circular widget. + Disable the language of a file by right clicking the indicator widget. """) .multilineTextAlignment(.center) .padding() diff --git a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureEnabledProjectListView.swift b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureEnabledProjectListView.swift index f57cd5e4..0cf66ca6 100644 --- a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureEnabledProjectListView.swift +++ b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureEnabledProjectListView.swift @@ -82,7 +82,7 @@ struct SuggestionFeatureEnabledProjectListView: View { Text(""" Empty Add project with "+" button - Or right clicking the circular widget + Or right clicking the indicator widget """) .multilineTextAlignment(.center) } diff --git a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsGeneralSectionView.swift b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsGeneralSectionView.swift index 272302e8..390c7f98 100644 --- a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsGeneralSectionView.swift +++ b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsGeneralSectionView.swift @@ -263,6 +263,8 @@ struct SuggestionSettingsGeneralSectionView: View { var needControl @AppStorage(\.acceptSuggestionWithModifierOnlyForSwift) var onlyForSwift + @AppStorage(\.acceptSuggestionLineWithModifierControl) + var acceptLineWithControl } @StateObject var settings = Settings() @@ -290,6 +292,12 @@ struct SuggestionSettingsGeneralSectionView: View { Toggle(isOn: $settings.onlyForSwift) { Text("Only require modifiers for Swift") } + + Divider() + + Toggle(isOn: $settings.acceptLineWithControl) { + Text("Accept suggestion first line with Control") + } } .padding() diff --git a/Core/Sources/HostApp/FeatureSettingsView.swift b/Core/Sources/HostApp/FeatureSettingsView.swift index 4df4f10c..e8c1e38f 100644 --- a/Core/Sources/HostApp/FeatureSettingsView.swift +++ b/Core/Sources/HostApp/FeatureSettingsView.swift @@ -1,6 +1,11 @@ import SwiftUI +import SharedUIComponents struct FeatureSettingsView: View { + var tabContainer: ExternalTabContainer { + ExternalTabContainer.tabContainer(for: "Features") + } + @State var tag = 0 var body: some View { @@ -38,20 +43,20 @@ struct FeatureSettingsView: View { tag: 3, title: "Xcode", subtitle: "Xcode related features", - image: "app" + image: "hammer.circle" ) - -// #if canImport(ProHostApp) -// ScrollView { -// TerminalSettingsView().padding() -// } -// .sidebarItem( -// tag: 3, -// title: "Terminal", -// subtitle: "Terminal chat tab", -// image: "terminal" -// ) -// #endif + + ForEach(Array(tabContainer.tabs.enumerated()), id: \.1.id) { index, tab in + ScrollView { + tab.viewBuilder().padding() + } + .sidebarItem( + tag: 4 + index, + title: tab.title, + subtitle: tab.description, + image: tab.image + ) + } } } } @@ -62,4 +67,3 @@ struct FeatureSettingsView_Previews: PreviewProvider { .frame(width: 800) } } - diff --git a/Core/Sources/HostApp/GeneralView.swift b/Core/Sources/HostApp/GeneralView.swift index 6b08c69b..b69c0127 100644 --- a/Core/Sources/HostApp/GeneralView.swift +++ b/Core/Sources/HostApp/GeneralView.swift @@ -280,7 +280,7 @@ struct GeneralSettingsView: View { } Toggle(isOn: $settings.hideCircularWidget) { - Text("Hide circular widget") + Text("Hide indicator widget") } }.padding() } diff --git a/Core/Sources/HostApp/HostApp.swift b/Core/Sources/HostApp/HostApp.swift index ce66cbcf..f2b90303 100644 --- a/Core/Sources/HostApp/HostApp.swift +++ b/Core/Sources/HostApp/HostApp.swift @@ -18,6 +18,7 @@ struct HostApp { var general = General.State() var chatModelManagement = ChatModelManagement.State() var embeddingModelManagement = EmbeddingModelManagement.State() + var webSearchSettings = WebSearchSettings.State() } enum Action { @@ -25,6 +26,7 @@ struct HostApp { case general(General.Action) case chatModelManagement(ChatModelManagement.Action) case embeddingModelManagement(EmbeddingModelManagement.Action) + case webSearchSettings(WebSearchSettings.Action) } @Dependency(\.toast) var toast @@ -45,6 +47,10 @@ struct HostApp { Scope(state: \.embeddingModelManagement, action: \.embeddingModelManagement) { EmbeddingModelManagement() } + + Scope(state: \.webSearchSettings, action: \.webSearchSettings) { + WebSearchSettings() + } Reduce { _, action in switch action { @@ -62,6 +68,9 @@ struct HostApp { case .embeddingModelManagement: return .none + + case .webSearchSettings: + return .none } } } diff --git a/Core/Sources/HostApp/ServiceView.swift b/Core/Sources/HostApp/ServiceView.swift index 2fff4bcf..bf81eb51 100644 --- a/Core/Sources/HostApp/ServiceView.swift +++ b/Core/Sources/HostApp/ServiceView.swift @@ -17,7 +17,7 @@ struct ServiceView: View { subtitle: "Suggestion", image: "globe" ) - + ScrollView { CodeiumView().padding() }.sidebarItem( @@ -26,7 +26,7 @@ struct ServiceView: View { subtitle: "Suggestion", image: "globe" ) - + ChatModelManagementView(store: store.scope( state: \.chatModelManagement, action: \.chatModelManagement @@ -36,7 +36,7 @@ struct ServiceView: View { subtitle: "Chat, Modification", image: "globe" ) - + EmbeddingModelManagementView(store: store.scope( state: \.embeddingModelManagement, action: \.embeddingModelManagement @@ -46,16 +46,17 @@ struct ServiceView: View { subtitle: "Chat, Modification", image: "globe" ) - - ScrollView { - BingSearchView().padding() - }.sidebarItem( + + WebSearchView(store: store.scope( + state: \.webSearchSettings, + action: \.webSearchSettings + )).sidebarItem( tag: 4, - title: "Bing Search", - subtitle: "Search Chat Plugin", + title: "Web Search", + subtitle: "Chat, Modification", image: "globe" ) - + ScrollView { OtherSuggestionServicesView().padding() }.sidebarItem( diff --git a/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift b/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift index d2062450..9c81038f 100644 --- a/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift +++ b/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift @@ -103,131 +103,143 @@ final class TabToAcceptSuggestion { let tab = 48 let esc = 53 - Logger.service.info("TabToAcceptSuggestion: \(keycode)") - switch keycode { case tab: - Logger.service.info("TabToAcceptSuggestion: Tab") + return handleTab(event.flags) + case esc: + return handleEsc(event.flags) + default: + return .unchanged + } + } - guard let fileURL = ThreadSafeAccessToXcodeInspector.shared.activeDocumentURL - else { - Logger.service.info("TabToAcceptSuggestion: No active document") - return .unchanged - } + func handleTab(_ flags: CGEventFlags) -> CGEventManipulation.Result { + Logger.service.info("TabToAcceptSuggestion: Tab") - let language = languageIdentifierFromFileURL(fileURL) + guard let fileURL = ThreadSafeAccessToXcodeInspector.shared.activeDocumentURL + else { + Logger.service.info("TabToAcceptSuggestion: No active document") + return .unchanged + } - func checkKeybinding() -> Bool { - if event.flags.contains(.maskHelp) { return false } + let language = languageIdentifierFromFileURL(fileURL) - let shouldCheckModifiers = if UserDefaults.shared - .value(for: \.acceptSuggestionWithModifierOnlyForSwift) - { - language == .builtIn(.swift) - } else { - true - } + if flags.contains(.maskHelp) { return .unchanged } - if shouldCheckModifiers { - if event.flags.contains(.maskShift) != UserDefaults.shared - .value(for: \.acceptSuggestionWithModifierShift) - { - return false - } - if event.flags.contains(.maskControl) != UserDefaults.shared - .value(for: \.acceptSuggestionWithModifierControl) - { - return false - } - if event.flags.contains(.maskAlternate) != UserDefaults.shared - .value(for: \.acceptSuggestionWithModifierOption) - { - return false - } - if event.flags.contains(.maskCommand) != UserDefaults.shared - .value(for: \.acceptSuggestionWithModifierCommand) - { - return false - } - } else { - if event.flags.contains(.maskShift) { return false } - if event.flags.contains(.maskControl) { return false } - if event.flags.contains(.maskAlternate) { return false } - if event.flags.contains(.maskCommand) { return false } - } - - return true + let requiredFlagsToTrigger: CGEventFlags = { + var all = CGEventFlags() + if UserDefaults.shared.value(for: \.acceptSuggestionWithModifierShift) { + all.insert(.maskShift) } - - guard - checkKeybinding(), - canTapToAcceptSuggestion - else { - Logger.service.info("TabToAcceptSuggestion: Feature not available") - return .unchanged + if UserDefaults.shared.value(for: \.acceptSuggestionWithModifierControl) { + all.insert(.maskControl) } - - guard ThreadSafeAccessToXcodeInspector.shared.activeXcode != nil - else { - Logger.service.info("TabToAcceptSuggestion: Xcode not found") - return .unchanged + if UserDefaults.shared.value(for: \.acceptSuggestionWithModifierOption) { + all.insert(.maskAlternate) } - guard let editor = ThreadSafeAccessToXcodeInspector.shared.focusedEditor - else { - Logger.service.info("TabToAcceptSuggestion: No editor found") - return .unchanged + if UserDefaults.shared.value(for: \.acceptSuggestionWithModifierCommand) { + all.insert(.maskCommand) } - guard let filespace = workspacePool.fetchFilespaceIfExisted(fileURL: fileURL) - else { - Logger.service.info("TabToAcceptSuggestion: No file found") - return .unchanged + if UserDefaults.shared.value(for: \.acceptSuggestionWithModifierOnlyForSwift) { + if language == .builtIn(.swift) { + return all + } else { + return [] + } + } else { + return all } - guard let presentingSuggestion = filespace.presentingSuggestion - else { - Logger.service.info("TabToAcceptSuggestion: No Suggestions found") + }() + + let flagsToAvoidWhenNotRequired: [CGEventFlags] = [ + .maskShift, .maskCommand, .maskHelp, .maskSecondaryFn, + ] + + guard flags.contains(requiredFlagsToTrigger) else { + Logger.service.info("TabToAcceptSuggestion: Modifier not found") + return .unchanged + } + + for flag in flagsToAvoidWhenNotRequired { + if flags.contains(flag), !requiredFlagsToTrigger.contains(flag) { return .unchanged } + } - let editorContent = editor.getContent() + guard canTapToAcceptSuggestion else { + Logger.service.info("TabToAcceptSuggestion: Feature not available") + return .unchanged + } - let shouldAcceptSuggestion = Self.checkIfAcceptSuggestion( - lines: editorContent.lines, - cursorPosition: editorContent.cursorPosition, - codeMetadata: filespace.codeMetadata, - presentingSuggestionText: presentingSuggestion.text + guard ThreadSafeAccessToXcodeInspector.shared.activeXcode != nil + else { + Logger.service.info("TabToAcceptSuggestion: Xcode not found") + return .unchanged + } + guard let editor = ThreadSafeAccessToXcodeInspector.shared.focusedEditor + else { + Logger.service.info("TabToAcceptSuggestion: No editor found") + return .unchanged + } + guard let filespace = workspacePool.fetchFilespaceIfExisted(fileURL: fileURL) + else { + Logger.service.info("TabToAcceptSuggestion: No file found") + return .unchanged + } + guard let presentingSuggestion = filespace.presentingSuggestion + else { + Logger.service.info( + "TabToAcceptSuggestion: No presenting found for \(filespace.fileURL.lastPathComponent), found \(filespace.suggestions.count) suggestion, index \(filespace.suggestionIndex)." ) + return .unchanged + } - if shouldAcceptSuggestion { - Logger.service.info("TabToAcceptSuggestion: Accept") - Task { await commandHandler.acceptSuggestion() } - return .discarded + let editorContent = editor.getContent() + + let shouldAcceptSuggestion = Self.checkIfAcceptSuggestion( + lines: editorContent.lines, + cursorPosition: editorContent.cursorPosition, + codeMetadata: filespace.codeMetadata, + presentingSuggestionText: presentingSuggestion.text + ) + + if shouldAcceptSuggestion { + Logger.service.info("TabToAcceptSuggestion: Accept") + if flags.contains(.maskControl), + !requiredFlagsToTrigger.contains(.maskControl) + { + Task { await commandHandler.acceptActiveSuggestionLineInGroup(atIndex: nil) + } } else { - Logger.service.info("TabToAcceptSuggestion: Should not accept") - return .unchanged + Task { await commandHandler.acceptSuggestion() } } - case esc: - guard - !event.flags.contains(.maskShift), - !event.flags.contains(.maskControl), - !event.flags.contains(.maskAlternate), - !event.flags.contains(.maskCommand), - !event.flags.contains(.maskHelp), - canEscToDismissSuggestion - else { return .unchanged } - - guard - let fileURL = ThreadSafeAccessToXcodeInspector.shared.activeDocumentURL, - ThreadSafeAccessToXcodeInspector.shared.activeXcode != nil, - let filespace = workspacePool.fetchFilespaceIfExisted(fileURL: fileURL), - filespace.presentingSuggestion != nil - else { return .unchanged } - - Task { await commandHandler.dismissSuggestion() } return .discarded - default: + } else { + Logger.service.info("TabToAcceptSuggestion: Should not accept") return .unchanged } } + + func handleEsc(_ flags: CGEventFlags) -> CGEventManipulation.Result { + guard + !flags.contains(.maskShift), + !flags.contains(.maskControl), + !flags.contains(.maskAlternate), + !flags.contains(.maskCommand), + !flags.contains(.maskHelp), + canEscToDismissSuggestion + else { return .unchanged } + + guard + let fileURL = ThreadSafeAccessToXcodeInspector.shared.activeDocumentURL, + ThreadSafeAccessToXcodeInspector.shared.activeXcode != nil, + let filespace = workspacePool.fetchFilespaceIfExisted(fileURL: fileURL), + filespace.presentingSuggestion != nil + else { return .unchanged } + + Task { await commandHandler.dismissSuggestion() } + return .discarded + } } extension TabToAcceptSuggestion { @@ -267,30 +279,47 @@ extension TabToAcceptSuggestion { } } -import Combine - private class ThreadSafeAccessToXcodeInspector { static let shared = ThreadSafeAccessToXcodeInspector() private(set) var activeDocumentURL: URL? private(set) var activeXcode: AppInstanceInspector? private(set) var focusedEditor: SourceEditor? - private var cancellable: Set = [] init() { - let inspector = XcodeInspector.shared + Task { [weak self] in + for await _ in NotificationCenter.default + .notifications(named: .activeDocumentURLDidChange) + { + guard let self else { return } + self.activeDocumentURL = await XcodeInspector.shared.activeDocumentURL + } + } - inspector.$activeDocumentURL.receive(on: DispatchQueue.main).sink { [weak self] newValue in - self?.activeDocumentURL = newValue - }.store(in: &cancellable) + Task { [weak self] in + for await _ in NotificationCenter.default + .notifications(named: .activeXcodeDidChange) + { + guard let self else { return } + self.activeXcode = await XcodeInspector.shared.activeXcode + } + } - inspector.$activeXcode.receive(on: DispatchQueue.main).sink { [weak self] newValue in - self?.activeXcode = newValue - }.store(in: &cancellable) + Task { [weak self] in + for await _ in NotificationCenter.default + .notifications(named: .focusedEditorDidChange) + { + guard let self else { return } + self.focusedEditor = await XcodeInspector.shared.focusedEditor + } + } - inspector.$focusedEditor.receive(on: DispatchQueue.main).sink { [weak self] newValue in - self?.focusedEditor = newValue - }.store(in: &cancellable) + // Initialize current values + Task { + activeDocumentURL = await XcodeInspector.shared.activeDocumentURL + activeXcode = await XcodeInspector.shared.activeApplication + focusedEditor = await XcodeInspector.shared.focusedEditor + } } } diff --git a/Core/Sources/LegacyChatPlugin/TerminalChatPlugin.swift b/Core/Sources/LegacyChatPlugin/TerminalChatPlugin.swift index 19a5d284..3ac8bd74 100644 --- a/Core/Sources/LegacyChatPlugin/TerminalChatPlugin.swift +++ b/Core/Sources/LegacyChatPlugin/TerminalChatPlugin.swift @@ -34,8 +34,8 @@ public actor TerminalChatPlugin: LegacyChatPlugin { } do { - let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL - let projectURL = await XcodeInspector.shared.safe.realtimeActiveProjectURL + let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL + let projectURL = XcodeInspector.shared.realtimeActiveProjectURL var environment = [String: String]() if let fileURL { 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/GUI/GraphicalUserInterfaceController.swift b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift index 66cf677e..dfbd719a 100644 --- a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift +++ b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift @@ -103,7 +103,7 @@ struct GUI { chatTabPool.removeTab(of: id) } - case let .chatTab(_, .openNewTab(builder)): + case let .chatTab(.element(_, .openNewTab(builder))): return .run { send in if let (_, chatTabInfo) = await chatTabPool .createTab(from: builder.chatTabBuilder) @@ -223,7 +223,7 @@ struct GUI { await send(.suggestionWidget(.circularWidget(.widgetClicked))) } - case let .suggestionWidget(.chatPanel(.chatTab(id, .tabContentUpdated))): + case let .suggestionWidget(.chatPanel(.chatTab(.element(id, .tabContentUpdated)))): #if canImport(ChatTabPersistent) // when a tab is updated, persist it. return .run { send in @@ -319,16 +319,18 @@ public final class GraphicalUserInterfaceController { state.chatTabGroup.tabInfo[id: id] ?? .init(id: id, title: "") }, action: { childAction in - .suggestionWidget(.chatPanel(.chatTab(id: id, action: childAction))) + .suggestionWidget(.chatPanel(.chatTab(.element( + id: id, + action: childAction + )))) } ) } suggestionDependency.suggestionWidgetDataSource = widgetDataSource - suggestionDependency.onOpenChatClicked = { [weak self] in - Task { [weak self] in - await self?.store.send(.createAndSwitchToChatGPTChatTabIfNeeded).finish() - self?.store.send(.openChatPanel(forceDetach: false, activateThisApp: true)) + suggestionDependency.onOpenChatClicked = { + Task { + PseudoCommandHandler().openChat(forceDetach: false, activateThisApp: true) } } suggestionDependency.onOpenModificationButtonClicked = { diff --git a/Core/Sources/Service/GlobalShortcutManager.swift b/Core/Sources/Service/GlobalShortcutManager.swift index cc799fc0..a3bb32b4 100644 --- a/Core/Sources/Service/GlobalShortcutManager.swift +++ b/Core/Sources/Service/GlobalShortcutManager.swift @@ -11,7 +11,7 @@ extension KeyboardShortcuts.Name { @MainActor final class GlobalShortcutManager { let guiController: GraphicalUserInterfaceController - private var cancellable = Set() + private var activeAppChangeTask: Task? nonisolated init(guiController: GraphicalUserInterfaceController) { self.guiController = guiController @@ -34,22 +34,30 @@ final class GlobalShortcutManager { } } - XcodeInspector.shared.$activeApplication.sink { app in - if !UserDefaults.shared.value(for: \.showHideWidgetShortcutGlobally) { - let shouldBeEnabled = if let app, app.isXcode || app.isExtensionService { - true + activeAppChangeTask?.cancel() + activeAppChangeTask = Task.detached { [weak self] in + let notifications = NotificationCenter.default + .notifications(named: .activeApplicationDidChange) + for await _ in notifications { + guard let self else { return } + try Task.checkCancellation() + if !UserDefaults.shared.value(for: \.showHideWidgetShortcutGlobally) { + let app = await XcodeInspector.shared.activeApplication + let shouldBeEnabled = if let app, app.isXcode || app.isExtensionService { + true + } else { + false + } + if shouldBeEnabled { + await self.setupShortcutIfNeeded() + } else { + await self.removeShortcutIfNeeded() + } } else { - false + await self.setupShortcutIfNeeded() } - if shouldBeEnabled { - self.setupShortcutIfNeeded() - } else { - self.removeShortcutIfNeeded() - } - } else { - self.setupShortcutIfNeeded() } - }.store(in: &cancellable) + } } func setupShortcutIfNeeded() { diff --git a/Core/Sources/Service/RealtimeSuggestionController.swift b/Core/Sources/Service/RealtimeSuggestionController.swift index 2d00c960..39770260 100644 --- a/Core/Sources/Service/RealtimeSuggestionController.swift +++ b/Core/Sources/Service/RealtimeSuggestionController.swift @@ -2,7 +2,6 @@ import ActiveApplicationMonitor import AppKit import AsyncAlgorithms import AXExtension -import Combine import Foundation import Logger import Preferences @@ -11,7 +10,7 @@ import Workspace import XcodeInspector public actor RealtimeSuggestionController { - private var cancellable: Set = [] + private var xcodeChangeObservationTask: Task? private var inflightPrefetchTask: Task? private var editorObservationTask: Task? private var sourceEditor: SourceEditor? @@ -19,7 +18,6 @@ public actor RealtimeSuggestionController { init() {} deinit { - cancellable.forEach { $0.cancel() } inflightPrefetchTask?.cancel() editorObservationTask?.cancel() } @@ -30,16 +28,18 @@ public actor RealtimeSuggestionController { } private func observeXcodeChange() { - cancellable.forEach { $0.cancel() } + xcodeChangeObservationTask?.cancel() - XcodeInspector.shared.$focusedEditor - .sink { [weak self] editor in + xcodeChangeObservationTask = Task { [weak self] in + for await _ in NotificationCenter.default + .notifications(named: .focusedEditorDidChange) + { guard let self else { return } - Task { - guard let editor else { return } - await self.handleFocusElementChange(editor) - } - }.store(in: &cancellable) + try Task.checkCancellation() + guard let editor = await XcodeInspector.shared.focusedEditor else { continue } + await self.handleFocusElementChange(editor) + } + } } private func handleFocusElementChange(_ sourceEditor: SourceEditor) { @@ -51,7 +51,7 @@ public actor RealtimeSuggestionController { editorObservationTask = nil editorObservationTask = Task { [weak self] in - if let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL { + if let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL { await PseudoCommandHandler().invalidateRealtimeSuggestionsIfNeeded( fileURL: fileURL, sourceEditor: sourceEditor @@ -86,7 +86,7 @@ public actor RealtimeSuggestionController { } group.addTask { let handler = { - guard let fileURL = await XcodeInspector.shared.safe.activeDocumentURL + guard let fileURL = await XcodeInspector.shared.activeDocumentURL else { return } await PseudoCommandHandler().invalidateRealtimeSuggestionsIfNeeded( fileURL: fileURL, @@ -113,7 +113,8 @@ public actor RealtimeSuggestionController { Task { @WorkspaceActor in // Get cache ready for real-time suggestions. guard UserDefaults.shared.value(for: \.preCacheOnFileOpen) else { return } - guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL + guard await XcodeInspector.shared.activeApplication?.isXcode ?? false else { return } + guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL else { return } let (_, filespace) = try await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) @@ -123,7 +124,7 @@ public actor RealtimeSuggestionController { // avoid the command get called twice filespace.codeMetadata.uti = "" do { - try await XcodeInspector.shared.safe.latestActiveXcode? + try await XcodeInspector.shared.latestActiveXcode? .triggerCopilotCommand(name: "Prepare for Real-time Suggestions") } catch { if filespace.codeMetadata.uti?.isEmpty ?? true { @@ -147,7 +148,7 @@ public actor RealtimeSuggestionController { else { return } if UserDefaults.shared.value(for: \.disableSuggestionFeatureGlobally), - let fileURL = await XcodeInspector.shared.safe.activeDocumentURL, + let fileURL = await XcodeInspector.shared.activeDocumentURL, let (workspace, _) = try? await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) { @@ -184,7 +185,7 @@ public actor RealtimeSuggestionController { } func notifyEditingFileChange(editor: AXUIElement) async { - guard let fileURL = await XcodeInspector.shared.safe.activeDocumentURL, + guard let fileURL = await XcodeInspector.shared.activeDocumentURL, let (workspace, _) = try? await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) else { return } diff --git a/Core/Sources/Service/ScheduledCleaner.swift b/Core/Sources/Service/ScheduledCleaner.swift index 0475baf9..b011bd78 100644 --- a/Core/Sources/Service/ScheduledCleaner.swift +++ b/Core/Sources/Service/ScheduledCleaner.swift @@ -34,7 +34,7 @@ public final class ScheduledCleaner { func cleanUp() async { guard let service else { return } - let workspaceInfos = XcodeInspector.shared.xcodes.reduce( + let workspaceInfos = await XcodeInspector.shared.xcodes.reduce( into: [ XcodeAppInstanceInspector.WorkspaceIdentifier: XcodeAppInstanceInspector.WorkspaceInfo diff --git a/Core/Sources/Service/Service.swift b/Core/Sources/Service/Service.swift index b222fb81..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,22 +97,25 @@ public final class Service { #if canImport(ProService) proService.start() #endif + overlayWindowController.start() DependencyUpdater().update() globalShortcutManager.start() keyBindingManager.start() - Task { - await XcodeInspector.shared.safe.$activeDocumentURL - .removeDuplicates() - .filter { $0 != .init(fileURLWithPath: "/") } - .compactMap { $0 } - .sink { fileURL in - Task { - @Dependency(\.workspacePool) var workspacePool - return try await workspacePool - .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) - } - }.store(in: &cancellable) + Task.detached { [weak self] in + let notifications = NotificationCenter.default + .notifications(named: .activeDocumentURLDidChange) + var previousURL: URL? + for await _ in notifications { + guard self != nil else { return } + let url = await XcodeInspector.shared.activeDocumentURL + if let url, url != previousURL, url != .init(fileURLWithPath: "/") { + previousURL = url + @Dependency(\.workspacePool) var workspacePool + _ = try await workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: url) + } + } } } @@ -158,6 +164,18 @@ public extension Service { } } } + + try ExtensionServiceRequests.GetSuggestionLineAcceptedCode.handle( + endpoint: endpoint, + requestBody: requestBody, + reply: reply + ) { request in + let editor = request.editorContent + let handler = WindowBaseCommandHandler() + let updatedContent = try? await handler + .acceptSuggestionLine(editor: editor) + return updatedContent + } } catch is XPCRequestHandlerHitError { return } catch { diff --git a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift index aad43195..c1d38d78 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift @@ -187,7 +187,7 @@ struct PseudoCommandHandler: CommandHandler { } }() else { do { - try await XcodeInspector.shared.safe.latestActiveXcode? + try await XcodeInspector.shared.latestActiveXcode? .triggerCopilotCommand(name: command.name) } catch { let presenter = PresentInWindowSuggestionPresenter() @@ -211,11 +211,11 @@ struct PseudoCommandHandler: CommandHandler { throw CancellationError() } do { - try await XcodeInspector.shared.safe.latestActiveXcode? + try await XcodeInspector.shared.latestActiveXcode? .triggerCopilotCommand(name: "Accept Modification") } catch { do { - try await XcodeInspector.shared.safe.latestActiveXcode? + try await XcodeInspector.shared.latestActiveXcode? .triggerCopilotCommand(name: "Accept Prompt to Code") } catch { let last = Self.lastTimeCommandFailedToTriggerWithAccessibilityAPI @@ -282,13 +282,75 @@ struct PseudoCommandHandler: CommandHandler { ), sendImmediately: false))) } + func acceptActiveSuggestionLineInGroup(atIndex index: Int?) async { + do { + if UserDefaults.shared.value(for: \.alwaysAcceptSuggestionWithAccessibilityAPI) { + throw CancellationError() + } + do { + try await XcodeInspector.shared.latestActiveXcode? + .triggerCopilotCommand(name: "Accept Suggestion Line") + } catch { + let last = Self.lastTimeCommandFailedToTriggerWithAccessibilityAPI + let now = Date() + if now.timeIntervalSince(last) > 60 * 60 { + Self.lastTimeCommandFailedToTriggerWithAccessibilityAPI = now + toast.toast(content: """ + The app is using a fallback solution to accept suggestions. \ + For better experience, please restart Xcode to re-activate the Copilot \ + menu item. + """, type: .warning, duration: 10) + } + + throw error + } + } catch { + guard let xcode = ActiveApplicationMonitor.shared.activeXcode + ?? ActiveApplicationMonitor.shared.latestXcode else { return } + let application = AXUIElementCreateApplication(xcode.processIdentifier) + guard let focusElement = application.focusedElement, + focusElement.description == "Source Editor" + else { return } + guard let ( + content, + lines, + _, + cursorPosition, + cursorOffset + ) = await getFileContent(sourceEditor: nil) + else { + PresentInWindowSuggestionPresenter() + .presentErrorMessage("Unable to get file content.") + return + } + let handler = WindowBaseCommandHandler() + do { + guard let result = try await handler.acceptSuggestion(editor: .init( + content: content, + lines: lines, + uti: "", + cursorPosition: cursorPosition, + cursorOffset: cursorOffset, + selections: [], + tabSize: 0, + indentSize: 0, + usesTabsForIndentation: false + )) else { return } + + try injectUpdatedCodeWithAccessibilityAPI(result, focusElement: focusElement) + } catch { + PresentInWindowSuggestionPresenter().presentError(error) + } + } + } + func acceptSuggestion() async { do { if UserDefaults.shared.value(for: \.alwaysAcceptSuggestionWithAccessibilityAPI) { throw CancellationError() } do { - try await XcodeInspector.shared.safe.latestActiveXcode? + try await XcodeInspector.shared.latestActiveXcode? .triggerCopilotCommand(name: "Accept Suggestion") } catch { let last = Self.lastTimeCommandFailedToTriggerWithAccessibilityAPI @@ -345,7 +407,7 @@ struct PseudoCommandHandler: CommandHandler { } func dismissSuggestion() async { - guard let documentURL = await XcodeInspector.shared.safe.activeDocumentURL else { return } + guard let documentURL = await XcodeInspector.shared.activeDocumentURL else { return } PresentInWindowSuggestionPresenter().discardSuggestion(fileURL: documentURL) guard let (_, filespace) = try? await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: documentURL) else { return } @@ -424,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, _): @@ -498,17 +559,28 @@ struct PseudoCommandHandler: CommandHandler { } } - func presentFile(at fileURL: URL, line: Int = 0) async { + func presentFile(at fileURL: URL, line: Int?) async { let terminal = Terminal() do { - _ = try await terminal.runCommand( - "/bin/bash", - arguments: [ - "-c", - "xed -l \(line) \"\(fileURL.path)\"", - ], - environment: [:] - ) + if let line { + _ = try await terminal.runCommand( + "/bin/bash", + arguments: [ + "-c", + "xed -l \(line) ${TARGET_FILE}", + ], + environment: ["TARGET_FILE": fileURL.path], + ) + } else { + _ = try await terminal.runCommand( + "/bin/bash", + arguments: [ + "-c", + "xed ${TARGET_FILE}", + ], + environment: ["TARGET_FILE": fileURL.path], + ) + } } catch { print(error) } @@ -597,7 +669,7 @@ extension PseudoCommandHandler { } func getFileURL() async -> URL? { - await XcodeInspector.shared.safe.realtimeActiveDocumentURL + XcodeInspector.shared.realtimeActiveDocumentURL } @WorkspaceActor @@ -615,7 +687,7 @@ extension PseudoCommandHandler { guard let filespace = await getFilespace(), let sourceEditor = await { if let sourceEditor { sourceEditor } - else { await XcodeInspector.shared.safe.focusedEditor } + else { await XcodeInspector.shared.latestFocusedEditor } }() else { return nil } if Task.isCancelled { return nil } @@ -638,5 +710,34 @@ extension PseudoCommandHandler { usesTabsForIndentation: usesTabsForIndentation ) } + + func handleAcceptSuggestionLineCommand(editor: EditorContent) async throws -> CodeSuggestion? { + guard let _ = XcodeInspector.shared.realtimeActiveDocumentURL + else { return nil } + + return try await acceptSuggestionLineInGroup( + atIndex: 0, + editor: editor + ) + } + + func acceptSuggestionLineInGroup( + atIndex index: Int?, + editor: EditorContent + ) async throws -> CodeSuggestion? { + guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL + else { return nil } + let (workspace, _) = try await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) + + guard var acceptedSuggestion = await workspace.acceptSuggestion( + forFileAt: fileURL, + editor: editor + ) else { return nil } + + let text = acceptedSuggestion.text + acceptedSuggestion.text = String(text.splitByNewLine().first ?? "") + return acceptedSuggestion + } } diff --git a/Core/Sources/Service/SuggestionCommandHandler/SuggestionCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/SuggestionCommandHandler.swift index 3d612e82..bc2742c9 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/SuggestionCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/SuggestionCommandHandler.swift @@ -11,6 +11,8 @@ protocol SuggestionCommandHandler { @ServiceActor func rejectSuggestion(editor: EditorContent) async throws -> UpdatedContent? @ServiceActor + func acceptSuggestionLine(editor: EditorContent) async throws -> UpdatedContent? + @ServiceActor func acceptSuggestion(editor: EditorContent) async throws -> UpdatedContent? @ServiceActor func acceptPromptToCode(editor: EditorContent) async throws -> UpdatedContent? @@ -23,3 +25,4 @@ protocol SuggestionCommandHandler { @ServiceActor func customCommand(id: String, editor: EditorContent) async throws -> UpdatedContent? } + diff --git a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift index 7f971649..53b0c833 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift @@ -42,7 +42,7 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { defer { presenter.markAsProcessing(false) } - guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL + guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL else { return } let (workspace, filespace) = try await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) @@ -76,7 +76,7 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { @WorkspaceActor private func _presentNextSuggestion(editor: EditorContent) async throws { - guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL + guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL else { return } let (workspace, filespace) = try await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) @@ -102,7 +102,7 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { @WorkspaceActor private func _presentPreviousSuggestion(editor: EditorContent) async throws { - guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL + guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL else { return } let (workspace, filespace) = try await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) @@ -128,7 +128,7 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { @WorkspaceActor private func _rejectSuggestion(editor: EditorContent) async throws { - guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL + guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL else { return } let (workspace, _) = try await Service.shared.workspacePool @@ -139,7 +139,7 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { @WorkspaceActor func acceptSuggestion(editor: EditorContent) async throws -> UpdatedContent? { - guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL + guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL else { return nil } let (workspace, _) = try await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) @@ -172,8 +172,34 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { return nil } + func acceptSuggestionLine(editor: EditorContent) async throws -> UpdatedContent? { + if let acceptedSuggestion = try await PseudoCommandHandler() + .handleAcceptSuggestionLineCommand(editor: editor) + { + let injector = SuggestionInjector() + var lines = editor.lines + var cursorPosition = editor.cursorPosition + var extraInfo = SuggestionInjector.ExtraInfo() + + injector.acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursorPosition, + completion: acceptedSuggestion, + extraInfo: &extraInfo + ) + + return .init( + content: String(lines.joined(separator: "")), + newSelection: .cursor(cursorPosition), + modifications: extraInfo.modifications + ) + } + + return nil + } + func acceptPromptToCode(editor: EditorContent) async throws -> UpdatedContent? { - guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL + guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL else { return nil } let injector = SuggestionInjector() @@ -260,7 +286,7 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { @WorkspaceActor func prepareCache(editor: EditorContent) async throws -> UpdatedContent? { - guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL + guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL else { return nil } let (_, filespace) = try await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) @@ -355,9 +381,9 @@ extension WindowBaseCommandHandler { generateDescription: Bool?, name: String? ) async throws { - guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL + guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL else { return } - let (workspace, filespace) = try await Service.shared.workspacePool + let (workspace, _) = try await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) guard workspace.suggestionPlugin?.isSuggestionFeatureEnabled ?? false else { presenter.presentErrorMessage("Prompt to code is disabled for this project") @@ -367,34 +393,16 @@ extension WindowBaseCommandHandler { let codeLanguage = languageIdentifierFromFileURL(fileURL) let selections: [CursorRange] = { - var all = [CursorRange]() - - // join the ranges if they overlaps in line - - for selection in editor.selections { - let range = CursorRange(start: selection.start, end: selection.end) - - func intersect(_ lhs: CursorRange, _ rhs: CursorRange) -> Bool { - lhs.start.line <= rhs.end.line && lhs.end.line >= rhs.start.line - } - - if let last = all.last, intersect(last, range) { - all[all.count - 1] = CursorRange( - start: .init( - line: min(last.start.line, range.start.line), - character: min(last.start.character, range.start.character) - ), - end: .init( - line: max(last.end.line, range.end.line), - character: max(last.end.character, range.end.character) - ) - ) - } else { - all.append(range) - } + if let firstSelection = editor.selections.first, + let lastSelection = editor.selections.last + { + let range = CursorRange( + start: firstSelection.start, + end: lastSelection.end + ) + return [range] } - - return all + return [] }() let snippets = selections.map { selection in diff --git a/Core/Sources/SuggestionWidget/ChatPanelWindow.swift b/Core/Sources/SuggestionWidget/ChatPanelWindow.swift index cad0c7c0..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( @@ -90,16 +87,6 @@ final class ChatPanelWindow: WidgetWindow { center() } - func setFloatOnTop(_ isFloatOnTop: Bool) { - let targetLevel: NSWindow.Level = isFloatOnTop - ? .init(NSWindow.Level.floating.rawValue + 1) - : .normal - - if targetLevel != level { - level = targetLevel - } - } - var isWindowHidden: Bool = false { didSet { alphaValue = isPanelDisplayed && !isWindowHidden ? 1 : 0 diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift index ee655d0b..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) @@ -125,7 +127,7 @@ struct ChatTitleBar: View { } } -private extension View { +extension View { func hideScrollIndicator() -> some View { if #available(macOS 13.0, *) { return scrollIndicators(.hidden) @@ -200,7 +202,7 @@ struct ChatTabBar: View { draggingTabId: $draggingTabId ) ) - + } else { ChatTabBarButton( store: store, diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanel.swift b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanel.swift index d782e6fd..28bf5bfc 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanel.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanel.swift @@ -77,7 +77,7 @@ public struct ChatPanel { case moveChatTab(from: Int, to: Int) case focusActiveChatTab - case chatTab(id: String, action: ChatTabItem.Action) + case chatTab(IdentifiedActionOf) } @Dependency(\.chatTabPool) var chatTabPool @@ -280,10 +280,10 @@ public struct ChatPanel { let id = state.chatTabGroup.selectedTabInfo?.id guard let id else { return .none } return .run { send in - await send(.chatTab(id: id, action: .focus)) + await send(.chatTab(.element(id: id, action: .focus))) } - case let .chatTab(id, .close): + case let .chatTab(.element(id, .close)): return .run { send in await send(.closeTabButtonClicked(id: id)) } @@ -291,7 +291,7 @@ public struct ChatPanel { case .chatTab: return .none } - }.forEach(\.chatTabGroup.tabInfo, action: /Action.chatTab) { + }.forEach(\.chatTabGroup.tabInfo, action: \.chatTab) { ChatTabItem() } } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift index 98ac96c9..d844b336 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift @@ -11,18 +11,15 @@ public struct PromptToCodeGroup { public var promptToCodes: IdentifiedArrayOf = [] public var activeDocumentURL: PromptToCodePanel.State.ID? = XcodeInspector.shared .realtimeActiveDocumentURL + public var selectedTabId: URL? public var activePromptToCode: PromptToCodePanel.State? { get { - if let detached = promptToCodes - .first(where: { !$0.promptToCodeState.isAttachedToTarget }) - { - return detached - } - guard let id = activeDocumentURL else { return nil } - return promptToCodes[id: id] + guard let selectedTabId else { return promptToCodes.first } + return promptToCodes[id: selectedTabId] ?? promptToCodes.first } set { - if let id = newValue?.id { + selectedTabId = newValue?.id + if let id = selectedTabId { promptToCodes[id: id] = newValue } } @@ -41,7 +38,11 @@ public struct PromptToCodeGroup { case discardAcceptedPromptToCodeIfNotContinuous(id: PromptToCodePanel.State.ID) case updateActivePromptToCode(documentURL: URL) case discardExpiredPromptToCode(documentURLs: [URL]) - case promptToCode(PromptToCodePanel.State.ID, PromptToCodePanel.Action) + case tabClicked(id: URL) + case closeTabButtonClicked(id: URL) + case switchToNextTab + case switchToPreviousTab + case promptToCode(IdentifiedActionOf) case activePromptToCode(PromptToCodePanel.Action) } @@ -51,22 +52,31 @@ public struct PromptToCodeGroup { Reduce { state, action in switch action { case let .activateOrCreatePromptToCode(s): - if let promptToCode = state.activePromptToCode { + if let promptToCode = state.activePromptToCode, s.id == promptToCode.id { + state.selectedTabId = promptToCode.id return .run { send in - await send(.promptToCode(promptToCode.id, .focusOnTextField)) + await send(.promptToCode(.element( + id: promptToCode.id, + action: .focusOnTextField + ))) } } return .run { send in await send(.createPromptToCode(s, sendImmediately: false)) } case let .createPromptToCode(newPromptToCode, sendImmediately): - // insert at 0 so it has high priority then the other detached prompt to codes - state.promptToCodes.insert(newPromptToCode, at: 0) - return .run { send in + var newPromptToCode = newPromptToCode + newPromptToCode.isActiveDocument = newPromptToCode.id == state.activeDocumentURL + state.promptToCodes.append(newPromptToCode) + state.selectedTabId = newPromptToCode.id + return .run { [newPromptToCode] send in if sendImmediately, !newPromptToCode.contextInputController.instruction.string.isEmpty { - await send(.promptToCode(newPromptToCode.id, .modifyCodeButtonTapped)) + await send(.promptToCode(.element( + id: newPromptToCode.id, + action: .modifyCodeButtonTapped + ))) } }.cancellable( id: PromptToCodePanel.CancellationKey.modifyCode(newPromptToCode.id), @@ -81,11 +91,21 @@ public struct PromptToCodeGroup { return .none case let .discardAcceptedPromptToCodeIfNotContinuous(id): - state.promptToCodes.removeAll { $0.id == id && $0.hasEnded } + for itemId in state.promptToCodes.ids { + if itemId == id, state.promptToCodes[id: itemId]?.clickedButton == .accept { + state.promptToCodes.remove(id: itemId) + } else { + state.promptToCodes[id: itemId]?.clickedButton = nil + } + } return .none case let .updateActivePromptToCode(documentURL): state.activeDocumentURL = documentURL + for index in state.promptToCodes.indices { + state.promptToCodes[index].isActiveDocument = + state.promptToCodes[index].id == documentURL + } return .none case let .discardExpiredPromptToCode(documentURLs): @@ -94,6 +114,37 @@ public struct PromptToCodeGroup { } return .none + case let .tabClicked(id): + state.selectedTabId = id + return .none + + case let .closeTabButtonClicked(id): + return .run { send in + await send(.promptToCode(.element( + id: id, + action: .cancelButtonTapped + ))) + } + + case .switchToNextTab: + if let selectedTabId = state.selectedTabId, + let index = state.promptToCodes.index(id: selectedTabId) + { + let nextIndex = (index + 1) % state.promptToCodes.count + state.selectedTabId = state.promptToCodes[nextIndex].id + } + return .none + + case .switchToPreviousTab: + if let selectedTabId = state.selectedTabId, + let index = state.promptToCodes.index(id: selectedTabId) + { + let previousIndex = (index - 1 + state.promptToCodes.count) % state + .promptToCodes.count + state.selectedTabId = state.promptToCodes[previousIndex].id + } + return .none + case .promptToCode: return .none @@ -104,22 +155,28 @@ public struct PromptToCodeGroup { .ifLet(\.activePromptToCode, action: \.activePromptToCode) { PromptToCodePanel() } - .forEach(\.promptToCodes, action: /Action.promptToCode, element: { + .forEach(\.promptToCodes, action: \.promptToCode, element: { PromptToCodePanel() }) Reduce { state, action in switch action { - case let .promptToCode(id, .cancelButtonTapped): + case let .promptToCode(.element(id, .cancelButtonTapped)): state.promptToCodes.remove(id: id) + let isEmpty = state.promptToCodes.isEmpty return .run { _ in - activatePreviousActiveXcode() + if isEmpty { + activatePreviousActiveXcode() + } } case .activePromptToCode(.cancelButtonTapped): - guard let id = state.activePromptToCode?.id else { return .none } + guard let id = state.selectedTabId else { return .none } state.promptToCodes.remove(id: id) + let isEmpty = state.promptToCodes.isEmpty return .run { _ in - activatePreviousActiveXcode() + if isEmpty { + activatePreviousActiveXcode() + } } default: return .none } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift index f432f1c4..cb68435f 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift @@ -1,4 +1,5 @@ import AppKit +import ChatBasic import ComposableArchitecture import CustomAsyncAlgorithms import Dependencies @@ -8,6 +9,7 @@ import Preferences import PromptToCodeCustomization import PromptToCodeService import SuggestionBasic +import XcodeInspector @Reducer public struct PromptToCodePanel { @@ -17,6 +19,11 @@ public struct PromptToCodePanel { case textField } + public enum ClickedButton: Equatable { + case accept + case acceptAndContinue + } + @Shared public var promptToCodeState: ModificationState @ObservationStateIgnored public var contextInputController: PromptToCodeContextInputController @@ -35,7 +42,9 @@ public struct PromptToCodePanel { public var generateDescriptionRequirement: Bool - public var hasEnded = false + public var clickedButton: ClickedButton? + + public var isActiveDocument: Bool = false public var snippetPanels: IdentifiedArrayOf { get { @@ -84,7 +93,9 @@ public struct PromptToCodePanel { case cancelButtonTapped case acceptButtonTapped case acceptAndContinueButtonTapped + case revealFileButtonClicked case statusUpdated([String]) + case referencesUpdated([ChatMessage.Reference]) case snippetPanel(IdentifiedActionOf) } @@ -120,18 +131,19 @@ public struct PromptToCodePanel { let copiedState = state let contextInputController = state.contextInputController state.promptToCodeState.isGenerating = true - state.promptToCodeState - .pushHistory(instruction: .init( - attributedString: contextInputController - .instruction - )) + state.promptToCodeState.pushHistory(instruction: .init( + attributedString: contextInputController.instruction + )) + state.promptToCodeState.references = [] let snippets = state.promptToCodeState.snippets return .run { send in do { - let context = await contextInputController.resolveContext(onStatusChange: { - await send(.statusUpdated($0)) - }) + let context = await contextInputController.resolveContext( + forDocumentURL: copiedState.promptToCodeState.source.documentURL, + onStatusChange: { await send(.statusUpdated($0)) } + ) + await send(.referencesUpdated(context.references)) let agentFactory = context.agent ?? { SimpleModificationAgent() } _ = try await withThrowingTaskGroup(of: Void.self) { group in for (index, snippet) in snippets.enumerated() { @@ -161,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, @@ -246,21 +273,33 @@ public struct PromptToCodePanel { return .cancel(id: CancellationKey.modifyCode(state.id)) case .acceptButtonTapped: - state.hasEnded = true + state.clickedButton = .accept return .run { _ in await commandHandler.acceptModification() activatePreviousActiveXcode() } case .acceptAndContinueButtonTapped: + state.clickedButton = .acceptAndContinue return .run { _ in await commandHandler.acceptModification() activateThisApp() } - + + case .revealFileButtonClicked: + let url = state.promptToCodeState.source.documentURL + let startLine = state.snippetPanels.first?.snippet.attachedRange.start.line ?? 0 + return .run { _ in + await commandHandler.presentFile(at: url, line: startLine) + } + case let .statusUpdated(status): state.promptToCodeState.status = status return .none + + case let .referencesUpdated(references): + state.promptToCodeState.references = references + return .none } } @@ -292,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/FeatureReducers/SharedPanel.swift b/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanel.swift index b255949c..9f38210e 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanel.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanel.swift @@ -7,7 +7,6 @@ public struct SharedPanel { public struct Content { public var promptToCodeGroup = PromptToCodeGroup.State() var suggestion: PresentingCodeSuggestion? - public var promptToCode: PromptToCodePanel.State? { promptToCodeGroup.activePromptToCode } var error: String? } @@ -19,7 +18,7 @@ public struct SharedPanel { var isPanelDisplayed: Bool = false var isEmpty: Bool { if content.error != nil { return false } - if content.promptToCode != nil { return false } + if !content.promptToCodeGroup.promptToCodes.isEmpty { return false } if content.suggestion != nil, UserDefaults.shared .value(for: \.suggestionPresentationMode) == .floatingWidget { return false } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/Widget.swift b/Core/Sources/SuggestionWidget/FeatureReducers/Widget.swift index b8c20fa7..493628fc 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/Widget.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/Widget.swift @@ -232,12 +232,18 @@ public struct Widget { case .observeActiveApplicationChange: return .run { send in let stream = AsyncStream { continuation in - let cancellable = xcodeInspector.$activeApplication.sink { newValue in - guard let newValue else { return } - continuation.yield(newValue) + let task = Task { + let notifications = NotificationCenter.default + .notifications(named: .activeApplicationDidChange) + for await _ in notifications { + try Task.checkCancellation() + if let app = await XcodeInspector.shared.activeApplication { + continuation.yield(app) + } + } } continuation.onTermination = { _ in - cancellable.cancel() + task.cancel() } } @@ -305,8 +311,7 @@ public struct Widget { case .updateFocusingDocumentURL: return .run { send in await send(.setFocusingDocumentURL( - to: await xcodeInspector.safe - .realtimeActiveDocumentURL + to: xcodeInspector.realtimeActiveDocumentURL )) } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetPanel.swift b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetPanel.swift index 059a377d..7d911f75 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetPanel.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetPanel.swift @@ -55,7 +55,7 @@ public struct WidgetPanel { switch action { case .presentSuggestion: return .run { send in - guard let fileURL = await xcodeInspector.safe.activeDocumentURL, + guard let fileURL = await xcodeInspector.activeDocumentURL, let provider = await fetchSuggestionProvider(fileURL: fileURL) else { return } await send(.presentSuggestionProvider(provider, displayContent: true)) @@ -101,7 +101,7 @@ public struct WidgetPanel { case .switchToAnotherEditorAndUpdateContent: return .run { send in - guard let fileURL = await xcodeInspector.safe.realtimeActiveDocumentURL + guard let fileURL = xcodeInspector.realtimeActiveDocumentURL else { return } await send(.sharedPanel( @@ -118,7 +118,7 @@ public struct WidgetPanel { case .sharedPanel(.promptToCodeGroup(.activateOrCreatePromptToCode)), .sharedPanel(.promptToCodeGroup(.createPromptToCode)): - let hasPromptToCode = state.content.promptToCode != nil + let hasPromptToCode = !state.content.promptToCodeGroup.promptToCodes.isEmpty return .run { send in await send(.displayPanelContent) diff --git a/Core/Sources/SuggestionWidget/PromptToCodePanelGroupView.swift b/Core/Sources/SuggestionWidget/PromptToCodePanelGroupView.swift new file mode 100644 index 00000000..739fe6b7 --- /dev/null +++ b/Core/Sources/SuggestionWidget/PromptToCodePanelGroupView.swift @@ -0,0 +1,141 @@ +import ComposableArchitecture +import Foundation +import SwiftUI + +struct PromptToCodePanelGroupView: View { + let store: StoreOf + + var body: some View { + WithPerceptionTracking { + VStack(spacing: 0) { + PromptToCodeTabBar(store: store) + .frame(height: 26) + + Divider() + + if let store = self.store.scope( + state: \.activePromptToCode, + action: \.activePromptToCode + ) { + PromptToCodePanelView(store: store) + } + } + .background(.ultraThickMaterial) + .xcodeStyleFrame() + } + } +} + +struct PromptToCodeTabBar: View { + let store: StoreOf + + struct TabInfo: Equatable, Identifiable { + var id: URL + var tabTitle: String + var isProcessing: Bool + } + + var body: some View { + HStack(spacing: 0) { + Tabs(store: store) + } + .background { + Button(action: { store.send(.switchToNextTab) }) { EmptyView() } + .opacity(0) + .keyboardShortcut("]", modifiers: [.command, .shift]) + Button(action: { store.send(.switchToPreviousTab) }) { EmptyView() } + .opacity(0) + .keyboardShortcut("[", modifiers: [.command, .shift]) + } + } + + struct Tabs: View { + let store: StoreOf + + var body: some View { + WithPerceptionTracking { + let tabInfo = store.promptToCodes.map { + TabInfo( + id: $0.id, + tabTitle: $0.filename, + isProcessing: $0.promptToCodeState.isGenerating + ) + } + let selectedTabId = store.selectedTabId + ?? store.promptToCodes.first?.id + + ScrollViewReader { proxy in + ScrollView(.horizontal) { + HStack(spacing: 0) { + ForEach(tabInfo) { info in + WithPerceptionTracking { + PromptToCodeTabBarButton( + store: store, + info: info, + isSelected: info.id == store.selectedTabId + ) + .id(info.id) + } + } + } + } + .hideScrollIndicator() + .onChange(of: selectedTabId) { id in + withAnimation(.easeInOut(duration: 0.2)) { + proxy.scrollTo(id) + } + } + } + } + } + } +} + +struct PromptToCodeTabBarButton: View { + let store: StoreOf + let info: PromptToCodeTabBar.TabInfo + let isSelected: Bool + @State var isHovered: Bool = false + + var body: some View { + HStack(spacing: 0) { + HStack(spacing: 4) { + if info.isProcessing { + ProgressView() + .controlSize(.small) + } + Text(info.tabTitle) + .truncationMode(.middle) + .allowsTightening(true) + } + .font(.callout) + .lineLimit(1) + .frame(maxWidth: 120) + .padding(.horizontal, 28) + .contentShape(Rectangle()) + .onTapGesture { + store.send(.tabClicked(id: info.id)) + } + .overlay(alignment: .leading) { + Button(action: { + store.send(.closeTabButtonClicked(id: info.id)) + }) { + Image(systemName: "xmark") + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + .padding(2) + .padding(.leading, 8) + .opacity(isHovered ? 1 : 0) + } + .onHover { isHovered = $0 } + .animation(.linear(duration: 0.1), value: isHovered) + .animation(.linear(duration: 0.1), value: isSelected) + + Divider().padding(.vertical, 6) + } + .background(isSelected ? Color(nsColor: .selectedControlColor) : Color.clear) + .frame(maxHeight: .infinity) + } +} + diff --git a/Core/Sources/SuggestionWidget/SharedPanelView.swift b/Core/Sources/SuggestionWidget/SharedPanelView.swift index 1c1c1a91..a00b2fee 100644 --- a/Core/Sources/SuggestionWidget/SharedPanelView.swift +++ b/Core/Sources/SuggestionWidget/SharedPanelView.swift @@ -72,7 +72,7 @@ struct SharedPanelView: View { ZStack(alignment: .topLeading) { if let errorMessage = store.content.error { error(errorMessage) - } else if let _ = store.content.promptToCode { + } else if !store.content.promptToCodeGroup.promptToCodes.isEmpty { promptToCode() } else if let suggestionProvider = store.content.suggestion { suggestion(suggestionProvider) @@ -93,12 +93,10 @@ struct SharedPanelView: View { @ViewBuilder func promptToCode() -> some View { - if let store = store.scope( - state: \.content.promptToCodeGroup.activePromptToCode, - action: \.promptToCodeGroup.activePromptToCode - ) { - PromptToCodePanelView(store: store) - } + PromptToCodePanelGroupView(store: store.scope( + state: \.content.promptToCodeGroup, + action: \.promptToCodeGroup + )) } @ViewBuilder diff --git a/Core/Sources/SuggestionWidget/Styles.swift b/Core/Sources/SuggestionWidget/Styles.swift index 825c571b..c2f1436b 100644 --- a/Core/Sources/SuggestionWidget/Styles.swift +++ b/Core/Sources/SuggestionWidget/Styles.swift @@ -8,9 +8,10 @@ enum Style { static let panelWidth: Double = 454 static let inlineSuggestionMinWidth: Double = 540 static let inlineSuggestionMaxHeight: Double = 400 - static let widgetHeight: Double = 20 - static var widgetWidth: Double { widgetHeight } + static let widgetHeight: Double = 30 + static var widgetWidth: Double = 8 static let widgetPadding: Double = 4 + static let indicatorBottomPadding: Double = 40 static let chatWindowTitleBarHeight: Double = 24 static let trafficLightButtonSize: Double = 12 } diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanelView.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanelView.swift index cef8a0ad..e876728f 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanelView.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanelView.swift @@ -54,6 +54,8 @@ struct CodeBlockSuggestionPanelView: View { struct ToolBar: View { @Dependency(\.commandHandler) var commandHandler + @Environment(\.modifierFlags) var modifierFlags + @AppStorage(\.acceptSuggestionLineWithModifierControl) var acceptLineWithControl let suggestion: PresentingCodeSuggestion var body: some View { @@ -98,14 +100,25 @@ struct CodeBlockSuggestionPanelView: View { Text("Reject") }.buttonStyle(CommandButtonStyle(color: .gray)) - Button(action: { - Task { - await commandHandler.acceptSuggestion() - NSWorkspace.activatePreviousActiveXcode() - } - }) { - Text("Accept") - }.buttonStyle(CommandButtonStyle(color: .accentColor)) + if modifierFlags.contains(.control) && acceptLineWithControl { + Button(action: { + Task { + await commandHandler.acceptActiveSuggestionLineInGroup(atIndex: nil) + NSWorkspace.activatePreviousActiveXcode() + } + }) { + Text("Accept Line") + }.buttonStyle(CommandButtonStyle(color: .gray)) + } else { + Button(action: { + Task { + await commandHandler.acceptSuggestion() + NSWorkspace.activatePreviousActiveXcode() + } + }) { + Text("Accept") + }.buttonStyle(CommandButtonStyle(color: .accentColor)) + } } .padding(6) .foregroundColor(.secondary) @@ -116,6 +129,8 @@ struct CodeBlockSuggestionPanelView: View { struct CompactToolBar: View { @Dependency(\.commandHandler) var commandHandler + @Environment(\.modifierFlags) var modifierFlags + @AppStorage(\.acceptSuggestionLineWithModifierControl) var acceptLineWithControl let suggestion: PresentingCodeSuggestion var body: some View { @@ -139,6 +154,12 @@ struct CodeBlockSuggestionPanelView: View { }.buttonStyle(.plain) Spacer() + + if modifierFlags.contains(.control) && acceptLineWithControl { + Text("Accept Line") + .foregroundColor(.secondary) + .padding(.trailing, 4) + } Button(action: { Task { @@ -256,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 930149ec..ef3b560c 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift @@ -1,3 +1,4 @@ +import ChatBasic import Cocoa import ComposableArchitecture import MarkdownUI @@ -38,8 +39,6 @@ struct PromptToCodePanelView: View { } } } - .background(.ultraThickMaterial) - .xcodeStyleFrame() } .task { await MainActor.run { @@ -56,14 +55,6 @@ extension PromptToCodePanelView { var body: some View { WithPerceptionTracking { VStack(spacing: 0) { - HStack { - SelectionRangeButton(store: store) - Spacer() - } - .padding(2) - - Divider() - if let previousStep = store.promptToCodeState.history.last { Button(action: { store.send(.revertButtonTapped) @@ -215,6 +206,7 @@ extension PromptToCodePanelView { var body: some View { HStack { + ReferencesButton(store: store) StopRespondingButton(store: store) ActionButtons(store: store) } @@ -249,6 +241,43 @@ extension PromptToCodePanelView { } } + struct ReferencesButton: View { + let store: StoreOf + @State var isReferencesPresented = false + @State var isReferencesHovered = false + + var body: some View { + if !store.promptToCodeState.references.isEmpty { + Button(action: { + isReferencesPresented.toggle() + }, label: { + HStack(spacing: 4) { + Image(systemName: "doc.text.magnifyingglass") + Text("\(store.promptToCodeState.references.count)") + } + .padding(8) + .background( + .regularMaterial, + in: RoundedRectangle(cornerRadius: 6, style: .continuous) + ) + .overlay { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + } + }) + .buttonStyle(.plain) + .popover(isPresented: $isReferencesPresented, arrowEdge: .trailing) { + ReferenceList(store: store) + } + .onHover { hovering in + withAnimation { + isReferencesHovered = hovering + } + } + } + } + } + struct ActionButtons: View { @Perception.Bindable var store: StoreOf @AppStorage(\.chatModels) var chatModels @@ -259,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 { @@ -298,8 +323,12 @@ extension PromptToCodePanelView { .buttonStyle(CommandButtonStyle(color: .gray)) .keyboardShortcut("w", modifiers: [.command]) - if !isCodeEmpty { - AcceptButton(store: store) + if store.isActiveDocument { + if !isCodeEmpty { + AcceptButton(store: store) + } + } else { + RevealButton(store: store) } } .fixedSize() @@ -368,6 +397,22 @@ extension PromptToCodePanelView { } } + struct RevealButton: View { + let store: StoreOf + + var body: some View { + WithPerceptionTracking { + Button(action: { + store.send(.revealFileButtonClicked) + }) { + Text("Jump to File(⌘ + ⏎)") + } + .buttonStyle(CommandButtonStyle(color: .accentColor)) + .keyboardShortcut(KeyEquivalent.return, modifiers: [.command]) + } + } + } + struct AcceptButton: View { let store: StoreOf @Environment(\.modifierFlags) var modifierFlags @@ -570,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) } } @@ -877,6 +925,157 @@ extension PromptToCodePanelView { } } } + + struct ReferenceList: View { + @Perception.Bindable var store: StoreOf + + var body: some View { + WithPerceptionTracking { + ScrollView { + VStack(alignment: .leading, spacing: 8) { + ForEach( + 0.. Void + + var body: some View { + Button(action: onClick) { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 4) { + ReferenceIcon(kind: reference.kind) + .layoutPriority(2) + Text(reference.title) + .truncationMode(.middle) + .lineLimit(1) + .layoutPriority(1) + .foregroundStyle(isUsed ? .primary : .secondary) + } + Text(reference.content) + .lineLimit(3) + .truncationMode(.tail) + .foregroundStyle(.tertiary) + .foregroundStyle(isUsed ? .secondary : .tertiary) + } + .padding(.vertical, 4) + .padding(.leading, 4) + .padding(.trailing) + .frame(maxWidth: .infinity, alignment: .leading) + .overlay { + RoundedRectangle(cornerRadius: 4) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + } + } + .buttonStyle(.plain) + } + } + } + + struct ReferenceIcon: View { + let kind: ChatMessage.Reference.Kind + + var body: some View { + RoundedRectangle(cornerRadius: 4) + .fill({ + switch kind { + case let .symbol(symbol, _, _, _): + switch symbol { + case .class: + Color.purple + case .struct: + Color.purple + case .enum: + Color.purple + case .actor: + Color.purple + case .protocol: + Color.purple + case .extension: + Color.indigo + case .case: + Color.green + case .property: + Color.teal + case .typealias: + Color.orange + case .function: + Color.teal + case .method: + Color.blue + } + case .text: + Color.gray + case .webpage: + Color.blue + case .textFile: + Color.gray + case .other: + Color.gray + case .error: + Color.red + } + }()) + .frame(width: 26, height: 14) + .overlay(alignment: .center) { + Group { + switch kind { + case let .symbol(symbol, _, _, _): + switch symbol { + case .class: + Text("C") + case .struct: + Text("S") + case .enum: + Text("E") + case .actor: + Text("A") + case .protocol: + Text("Pr") + case .extension: + Text("Ex") + case .case: + Text("K") + case .property: + Text("P") + case .typealias: + Text("T") + case .function: + Text("𝑓") + case .method: + Text("M") + } + case .text: + Text("Txt") + case .webpage: + Text("Web") + case .other: + Text("*") + case .textFile: + Text("Txt") + case .error: + Text("Err") + } + } + .font(.system(size: 10).monospaced()) + .foregroundColor(.white) + } + } + } } // MARK: - Previews @@ -906,7 +1105,7 @@ extension PromptToCodePanelView { end: .init(line: 12, character: 2) ) ), - ], instruction: .init("Previous instruction")), + ], instruction: .init("Previous instruction"), references: []), ], snippets: [ .init( @@ -941,7 +1140,19 @@ extension PromptToCodePanelView { ), ], extraSystemPrompt: "", - isAttachedToTarget: true + isAttachedToTarget: true, + references: [ + ChatMessage.Reference( + title: "Foo", + content: "struct Foo { var foo: Int }", + kind: .symbol( + .struct, + uri: "file:///path/to/file.txt", + startLine: 13, + endLine: 13 + ) + ), + ], )), instruction: nil, commandName: "Generate Code" diff --git a/Core/Sources/SuggestionWidget/TextCursorTracker.swift b/Core/Sources/SuggestionWidget/TextCursorTracker.swift index f73511a7..6de2dc29 100644 --- a/Core/Sources/SuggestionWidget/TextCursorTracker.swift +++ b/Core/Sources/SuggestionWidget/TextCursorTracker.swift @@ -1,9 +1,8 @@ -import Combine import Foundation import Perception import SuggestionBasic -import XcodeInspector import SwiftUI +import XcodeInspector /// A passive tracker that observe the changes of the source editor content. @Perceptible @@ -29,8 +28,7 @@ final class TextCursorTracker { lineAnnotations: [] ) - @PerceptionIgnored var editorObservationTask: Set = [] - @PerceptionIgnored var eventObservationTask: Task? + @PerceptionIgnored var eventObservationTask: Task? init() { observeAppChange() @@ -39,37 +37,38 @@ final class TextCursorTracker { deinit { eventObservationTask?.cancel() } - + var isPreview: Bool { ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" } private func observeAppChange() { if isPreview { return } - editorObservationTask = [] - Task { - await XcodeInspector.shared.safe.$focusedEditor.sink { [weak self] editor in - guard let editor, let self else { return } - Task { @MainActor in - self.observeAXNotifications(editor) - } - }.store(in: &editorObservationTask) + Task { [weak self] in + let notifications = NotificationCenter.default + .notifications(named: .focusedEditorDidChange) + for await _ in notifications { + guard let self else { return } + guard let editor = await XcodeInspector.shared.focusedEditor else { continue } + await self.observeAXNotifications(editor) + } } } - private func observeAXNotifications(_ editor: SourceEditor) { + private func observeAXNotifications(_ editor: SourceEditor) async { if isPreview { return } eventObservationTask?.cancel() let content = editor.getLatestEvaluatedContent() - Task { @MainActor in + await MainActor.run { self.content = content } eventObservationTask = Task { [weak self] in for await event in await editor.axNotifications.notifications() { + try Task.checkCancellation() guard let self else { return } guard event.kind == .evaluatedContentChanged else { continue } let content = editor.getLatestEvaluatedContent() - Task { @MainActor in + await MainActor.run { self.content = content } } @@ -87,3 +86,4 @@ extension EnvironmentValues { set { self[TextCursorTrackerEnvironmentKey.self] = newValue } } } + diff --git a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift index b7ceb487..5aed84b3 100644 --- a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift +++ b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift @@ -17,6 +17,7 @@ public struct WidgetLocation: Equatable { enum UpdateLocationStrategy { struct AlignToTextCursor { func framesForWindows( + windowFrame: CGRect, editorFrame: CGRect, mainScreen: NSScreen, activeScreen: NSScreen, @@ -33,6 +34,7 @@ enum UpdateLocationStrategy { ) else { return FixedToBottom().framesForWindows( + windowFrame: windowFrame, editorFrame: editorFrame, mainScreen: mainScreen, activeScreen: activeScreen, @@ -43,6 +45,7 @@ enum UpdateLocationStrategy { let found = AXValueGetValue(rect, .cgRect, &frame) guard found else { return FixedToBottom().framesForWindows( + windowFrame: windowFrame, editorFrame: editorFrame, mainScreen: mainScreen, activeScreen: activeScreen, @@ -51,6 +54,7 @@ enum UpdateLocationStrategy { } return HorizontalMovable().framesForWindows( y: mainScreen.frame.height - frame.maxY, + windowFrame: windowFrame, alignPanelTopToAnchor: nil, editorFrame: editorFrame, mainScreen: mainScreen, @@ -63,6 +67,7 @@ enum UpdateLocationStrategy { struct FixedToBottom { func framesForWindows( + windowFrame: CGRect, editorFrame: CGRect, mainScreen: NSScreen, activeScreen: NSScreen, @@ -73,6 +78,7 @@ enum UpdateLocationStrategy { ) -> WidgetLocation { var frames = HorizontalMovable().framesForWindows( y: mainScreen.frame.height - editorFrame.maxY + Style.widgetPadding, + windowFrame: windowFrame, alignPanelTopToAnchor: false, editorFrame: editorFrame, mainScreen: mainScreen, @@ -97,6 +103,7 @@ enum UpdateLocationStrategy { struct HorizontalMovable { func framesForWindows( y: CGFloat, + windowFrame: CGRect, alignPanelTopToAnchor fixedAlignment: Bool?, editorFrame: CGRect, mainScreen: NSScreen, @@ -130,6 +137,13 @@ enum UpdateLocationStrategy { width: Style.widgetWidth, height: Style.widgetHeight ) + + let widgetFrame = CGRect( + x: windowFrame.minX, + y: mainScreen.frame.height - windowFrame.maxY + Style.indicatorBottomPadding, + width: Style.widgetWidth, + height: Style.widgetHeight + ) if !hideCircularWidget { proposedAnchorFrameOnTheRightSide = widgetFrameOnTheRightSide @@ -150,7 +164,7 @@ enum UpdateLocationStrategy { x: proposedPanelX, y: alignPanelTopToAnchor ? anchorFrame.maxY - Style.panelHeight - : anchorFrame.minY - editorFrameExpendedSize.height, + : anchorFrame.minY, width: Style.panelWidth, height: Style.panelHeight ) @@ -164,7 +178,7 @@ enum UpdateLocationStrategy { ) return .init( - widgetFrame: widgetFrameOnTheRightSide, + widgetFrame: widgetFrame, tabFrame: tabFrame, sharedPanelLocation: .init( frame: panelFrame, @@ -227,7 +241,7 @@ enum UpdateLocationStrategy { height: Style.widgetHeight ) return .init( - widgetFrame: widgetFrameOnTheLeftSide, + widgetFrame: widgetFrame, tabFrame: tabFrame, sharedPanelLocation: .init( frame: panelFrame, @@ -244,10 +258,8 @@ enum UpdateLocationStrategy { let panelFrame = CGRect( x: anchorFrame.maxX - Style.panelWidth, y: alignPanelTopToAnchor - ? anchorFrame.maxY - Style.panelHeight - Style.widgetHeight - - Style.widgetPadding - : anchorFrame.maxY + Style.widgetPadding - - editorFrameExpendedSize.height, + ? anchorFrame.maxY - Style.panelHeight + : anchorFrame.maxY - editorFrameExpendedSize.height, width: Style.panelWidth, height: Style.panelHeight ) @@ -258,7 +270,7 @@ enum UpdateLocationStrategy { height: Style.widgetHeight ) return .init( - widgetFrame: widgetFrameOnTheRightSide, + widgetFrame: widgetFrame, tabFrame: tabFrame, sharedPanelLocation: .init( frame: panelFrame, @@ -426,10 +438,6 @@ enum UpdateLocationStrategy { let foundFirstLine = AXValueGetValue(selectedRange, .cfRange, &firstLineRange) firstLineRange.length = 0 - #warning( - "FIXME: When selection is too low and out of the screen, the selection range becomes something else." - ) - if foundFirstLine, let firstLineSelectionRange = AXValueCreate(.cfRange, &firstLineRange), let firstLineRect: AXValue = try? editor.copyParameterizedValue( diff --git a/Core/Sources/SuggestionWidget/WidgetView.swift b/Core/Sources/SuggestionWidget/WidgetView.swift index fdf50c87..f07816bf 100644 --- a/Core/Sources/SuggestionWidget/WidgetView.swift +++ b/Core/Sources/SuggestionWidget/WidgetView.swift @@ -1,6 +1,7 @@ import ActiveApplicationMonitor import ComposableArchitecture import Preferences +import SharedUIComponents import SuggestionBasic import SwiftUI @@ -13,18 +14,23 @@ struct WidgetView: View { @AppStorage(\.hideCircularWidget) var hideCircularWidget var body: some View { - WithPerceptionTracking { - Circle() - .fill(isHovering ? .white.opacity(0.5) : .white.opacity(0.15)) + GeometryReader { _ in + WithPerceptionTracking { + ZStack { + WidgetAnimatedCapsule( + store: store, + isHovering: isHovering + ) + } .onTapGesture { store.send(.widgetClicked, animation: .easeInOut(duration: 0.2)) } - .overlay { WidgetAnimatedCircle(store: store) } .onHover { yes in - withAnimation(.easeInOut(duration: 0.2)) { + withAnimation(.easeInOut(duration: 0.14)) { isHovering = yes } - }.contextMenu { + } + .contextMenu { WidgetContextMenu(store: store) } .opacity({ @@ -32,96 +38,113 @@ struct WidgetView: View { return store.isProcessing ? 1 : 0 }()) .animation( - featureFlag: \.animationCCrashSuggestion, .easeInOut(duration: 0.2), + value: isHovering + ) + .animation( + .easeInOut(duration: 0.4), value: store.isProcessing ) + } } } } -struct WidgetAnimatedCircle: View { +struct WidgetAnimatedCapsule: View { let store: StoreOf - @State var processingProgress: Double = 0 + var isHovering: Bool - struct OverlayCircleState: Equatable { - var isProcessing: Bool - var isContentEmpty: Bool - } + @State private var breathingOpacity: CGFloat = 1.0 + @State private var animationTask: Task? var body: some View { - WithPerceptionTracking { - let minimumLineWidth: Double = 3 - let lineWidth = (1 - processingProgress) * - (Style.widgetWidth - minimumLineWidth / 2) + minimumLineWidth - let scale = max(processingProgress * 1, 0.0001) - ZStack { - Circle() - .stroke( - Color(nsColor: .darkGray), - style: .init(lineWidth: minimumLineWidth) - ) - .padding(minimumLineWidth / 2) + GeometryReader { geo in + WithPerceptionTracking { + let capsuleWidth = geo.size.width + let capsuleHeight = geo.size.height - // how do I stop the repeatForever animation without removing the view? - // I tried many solutions found on stackoverflow but non of them works. - Group { - if store.isProcessing { - Circle() - .stroke( - Color.accentColor, - style: .init(lineWidth: lineWidth) - ) - .padding(minimumLineWidth / 2) - .scaleEffect(x: scale, y: scale) - .opacity( - !store.isContentEmpty || store.isProcessing ? 1 : 0 - ) - .animation( - featureFlag: \.animationCCrashSuggestion, - .easeInOut(duration: 1) - .repeatForever(autoreverses: true), - value: processingProgress - ) - } else { - Circle() - .stroke( - Color.accentColor, - style: .init(lineWidth: lineWidth) - ) - .padding(minimumLineWidth / 2) - .scaleEffect(x: scale, y: scale) - .opacity( - !store.isContentEmpty || store.isProcessing ? 1 : 0 - ) - .animation( - featureFlag: \.animationCCrashSuggestion, - .easeInOut(duration: 1), - value: processingProgress - ) - } + let backgroundWidth = capsuleWidth + let foregroundWidth = max(capsuleWidth - 4, 2) + let padding = (backgroundWidth - foregroundWidth) / 2 + let foregroundHeight = capsuleHeight - padding * 2 + + ZStack { + Capsule() + .modify { + if #available(macOS 26.0, *) { + $0.glassEffect() + } else if #available(macOS 13.0, *) { + $0.backgroundStyle(.thickMaterial.opacity(0.8)).overlay( + Capsule().stroke( + Color(nsColor: .darkGray).opacity(0.2), + lineWidth: 1 + ) + ) + } else { + $0.fill(Color(nsColor: .darkGray).opacity(0.6)).overlay( + Capsule().stroke( + Color(nsColor: .darkGray).opacity(0.2), + lineWidth: 1 + ) + ) + } + } + .frame(width: backgroundWidth, height: capsuleHeight) + + Capsule() + .fill(Color.white) + .frame( + width: foregroundWidth, + height: foregroundHeight + ) + .opacity({ + let base = store.isProcessing ? breathingOpacity : 0 + if isHovering { + return min(base + 0.5, 1.0) + } + return base + }()) + .blur(radius: 2) } - .onChange(of: store.isProcessing) { _ in - refreshRing( - isProcessing: store.isProcessing, - isContentEmpty: store.isContentEmpty - ) + .onAppear { + updateBreathingAnimation(isProcessing: store.isProcessing) } - .onChange(of: store.isContentEmpty) { _ in - refreshRing( - isProcessing: store.isProcessing, - isContentEmpty: store.isContentEmpty - ) + .onChange(of: store.isProcessing) { newValue in + updateBreathingAnimation(isProcessing: newValue) } } } } - func refreshRing(isProcessing: Bool, isContentEmpty: Bool) { + private func updateBreathingAnimation(isProcessing: Bool) { + animationTask?.cancel() + animationTask = nil + if isProcessing { - processingProgress = 1 - processingProgress + animationTask = Task { + while !Task.isCancelled { + await MainActor.run { + 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) { break } + await MainActor.run { + 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) { break } + } + } } else { - processingProgress = isContentEmpty ? 0 : 1 + withAnimation(.easeInOut(duration: 0.2)) { + breathingOpacity = 0 + } } } } @@ -145,11 +168,11 @@ struct WidgetContextMenu: View { }) { Text("Open Chat") } - + Button(action: { store.send(.openModificationButtonClicked) }) { - Text("Write or Modify Code") + Text("Write or Edit Code") } customCommandMenu() @@ -267,6 +290,7 @@ struct WidgetView_Preview: PreviewProvider { ), isHovering: false ) + .frame(width: Style.widgetWidth, height: Style.widgetHeight) WidgetView( store: Store( @@ -281,6 +305,7 @@ struct WidgetView_Preview: PreviewProvider { ), isHovering: true ) + .frame(width: Style.widgetWidth, height: Style.widgetHeight) WidgetView( store: Store( @@ -295,6 +320,7 @@ struct WidgetView_Preview: PreviewProvider { ), isHovering: false ) + .frame(width: Style.widgetWidth, height: Style.widgetHeight) WidgetView( store: Store( @@ -309,8 +335,9 @@ struct WidgetView_Preview: PreviewProvider { ), isHovering: false ) + .frame(width: Style.widgetWidth, height: Style.widgetHeight) } - .frame(width: 30) + .frame(width: 200, height: 200) .background(Color.black) } } diff --git a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift index e0e25de4..2f70e0e3 100644 --- a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift +++ b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift @@ -1,11 +1,11 @@ import AppKit import AsyncAlgorithms import ChatTab -import Combine import ComposableArchitecture import Dependencies import Foundation import SharedUIComponents +import SwiftNavigation import SwiftUI import XcodeInspector @@ -23,7 +23,6 @@ actor WidgetWindowsController: NSObject { var currentApplicationProcessIdentifier: pid_t? - var cancellable: Set = [] var observeToAppTask: Task? var observeToFocusedEditorTask: Task? @@ -56,23 +55,30 @@ actor WidgetWindowsController: NSObject { } func start() { - cancellable.removeAll() - - xcodeInspector.$activeApplication.sink { [weak self] app in - guard let app else { return } - Task { [weak self] in await self?.activate(app) } - }.store(in: &cancellable) + Task { [xcodeInspector] in + await observe { [weak self] in + if let app = xcodeInspector.activeApplication { + Task { + await self?.activate(app) + } + } + } - xcodeInspector.$focusedEditor.sink { [weak self] editor in - guard let editor else { return } - Task { [weak self] in await self?.observe(toEditor: editor) } - }.store(in: &cancellable) + await observe { [weak self] in + if let editor = xcodeInspector.focusedEditor { + Task { + await self?.observe(toEditor: editor) + } + } + } - xcodeInspector.$completionPanel.sink { [weak self] newValue in - Task { [weak self] in - await self?.handleCompletionPanelChange(isDisplaying: newValue != nil) + await observe { [weak self] in + let isDisplaying = xcodeInspector.completionPanel != nil + Task { + await self?.handleCompletionPanelChange(isDisplaying: isDisplaying) + } } - }.store(in: &cancellable) + } userDefaultsObservers.presentationModeChangeObserver.onChange = { [weak self] in Task { [weak self] in @@ -97,6 +103,10 @@ actor WidgetWindowsController: NSObject { } } } + + Task { @MainActor in + windows.chatPanelWindow.isPanelDisplayed = false + } } } @@ -114,6 +124,7 @@ private extension WidgetWindowsController { await hideSuggestionPanelWindow() } await adjustChatPanelWindowLevel() + await adjustModificationPanelLevel() } guard currentApplicationProcessIdentifier != app.processIdentifier else { return } currentApplicationProcessIdentifier = app.processIdentifier @@ -127,36 +138,38 @@ private extension WidgetWindowsController { observeToAppTask = Task { await windows.orderFront() - for await notification in await notifications.notifications() { - try Task.checkCancellation() - - /// Hide the widgets before switching to another window/editor - /// so the transition looks better. - func hideWidgetForTransitions() async { - let newDocumentURL = await xcodeInspector.safe.realtimeActiveDocumentURL - let documentURL = await MainActor - .run { store.withState { $0.focusingDocumentURL } } - if documentURL != newDocumentURL { - await send(.panel(.removeDisplayedContent)) - await hidePanelWindows() - } - await send(.updateFocusingDocumentURL) - } - - func removeContent() async { + /// Hide the widgets before switching to another window/editor + /// so the transition looks better. + func hideWidgetForTransitions() async { + let newDocumentURL = xcodeInspector.realtimeActiveDocumentURL + let documentURL = await MainActor + .run { store.withState { $0.focusingDocumentURL } } + if documentURL != newDocumentURL { await send(.panel(.removeDisplayedContent)) + await hidePanelWindows() } + await send(.updateFocusingDocumentURL) + } - func updateWidgetsAndNotifyChangeOfEditor(immediately: Bool) async { - await send(.panel(.switchToAnotherEditorAndUpdateContent)) - updateWindowLocation(animated: false, immediately: immediately) - updateWindowOpacity(immediately: immediately) - } + func removeContent() async { + await send(.panel(.removeDisplayedContent)) + } - func updateWidgets(immediately: Bool) async { - updateWindowLocation(animated: false, immediately: immediately) - updateWindowOpacity(immediately: immediately) - } + func updateWidgetsAndNotifyChangeOfEditor(immediately: Bool) async { + await send(.panel(.switchToAnotherEditorAndUpdateContent)) + updateWindowLocation(animated: false, immediately: immediately) + updateWindowOpacity(immediately: immediately) + } + + func updateWidgets(immediately: Bool) async { + updateWindowLocation(animated: false, immediately: immediately) + updateWindowOpacity(immediately: immediately) + } + + await updateWidgetsAndNotifyChangeOfEditor(immediately: true) + + for await notification in await notifications.notifications() { + try Task.checkCancellation() switch notification.kind { case .focusedWindowChanged: @@ -202,7 +215,7 @@ private extension WidgetWindowsController { selectionRangeChange.debounce(for: Duration.milliseconds(500)), scroll ) { - guard await xcodeInspector.safe.latestActiveXcode != nil else { return } + guard await xcodeInspector.latestActiveXcode != nil else { return } try Task.checkCancellation() // for better looking @@ -215,7 +228,7 @@ private extension WidgetWindowsController { } } else { for await notification in merge(selectionRangeChange, scroll) { - guard await xcodeInspector.safe.latestActiveXcode != nil else { return } + guard await xcodeInspector.latestActiveXcode != nil else { return } try Task.checkCancellation() // for better looking @@ -252,7 +265,7 @@ private extension WidgetWindowsController { extension WidgetWindowsController { @MainActor func hidePanelWindows() { - windows.sharedPanelWindow.alphaValue = 0 +// windows.sharedPanelWindow.alphaValue = 0 windows.suggestionPanelWindow.alphaValue = 0 } @@ -261,13 +274,20 @@ extension WidgetWindowsController { windows.suggestionPanelWindow.alphaValue = 0 } - func generateWidgetLocation() -> WidgetLocation? { - if let application = xcodeInspector.latestActiveXcode?.appElement { - if let focusElement = xcodeInspector.focusedEditor?.element, + func generateWidgetLocation() async -> WidgetLocation? { + if let application = await xcodeInspector.latestActiveXcode?.appElement { + if let window = application.focusedWindow, + let windowFrame = window.rect, + 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 firstScreen = NSScreen.main + 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) @@ -277,19 +297,21 @@ extension WidgetWindowsController { switch positionMode { case .fixedToBottom: var result = UpdateLocationStrategy.FixedToBottom().framesForWindows( + windowFrame: windowFrame, editorFrame: frame, mainScreen: screen, - activeScreen: firstScreen + activeScreen: windowContainingScreen ) switch suggestionMode { case .nearbyTextCursor: result.suggestionPanelLocation = UpdateLocationStrategy .NearbyTextCursor() .framesForSuggestionWindow( - editorFrame: frame, mainScreen: screen, - activeScreen: firstScreen, + editorFrame: frame, + mainScreen: screen, + activeScreen: windowContainingScreen, editor: focusElement, - completionPanel: xcodeInspector.completionPanel + completionPanel: await xcodeInspector.completionPanel ) default: break @@ -297,9 +319,10 @@ extension WidgetWindowsController { return result case .alignToTextCursor: var result = UpdateLocationStrategy.AlignToTextCursor().framesForWindows( + windowFrame: windowFrame, editorFrame: frame, mainScreen: screen, - activeScreen: firstScreen, + activeScreen: windowContainingScreen, editor: focusElement ) switch suggestionMode { @@ -308,9 +331,9 @@ extension WidgetWindowsController { .NearbyTextCursor() .framesForSuggestionWindow( editorFrame: frame, mainScreen: screen, - activeScreen: firstScreen, + activeScreen: windowContainingScreen, editor: focusElement, - completionPanel: xcodeInspector.completionPanel + completionPanel: await xcodeInspector.completionPanel ) default: break @@ -339,29 +362,18 @@ extension WidgetWindowsController { defaultPanelLocation: .init(frame: .zero, alignPanelTop: false) ) } - + window = workspaceWindow frame = rect } - var expendedSize = CGSize.zero - if ["Xcode.WorkspaceWindow"].contains(window.identifier) { - // extra padding to bottom so buttons won't be covered - frame.size.height -= 40 - } else { - // move a bit away from the window so buttons won't be covered - frame.origin.x -= Style.widgetPadding + Style.widgetWidth / 2 - frame.size.width += Style.widgetPadding * 2 + Style.widgetWidth - expendedSize.width = (Style.widgetPadding * 2 + Style.widgetWidth) / 2 - expendedSize.height += Style.widgetPadding - } - return UpdateLocationStrategy.FixedToBottom().framesForWindows( + windowFrame: frame, editorFrame: frame, mainScreen: screen, activeScreen: firstScreen, preferredInsideEditorMinWidth: 9_999_999_999, // never - editorFrameExpendedSize: expendedSize + editorFrameExpendedSize: .zero ) } } @@ -384,27 +396,18 @@ extension WidgetWindowsController { } try Task.checkCancellation() let xcodeInspector = self.xcodeInspector - let activeApp = await xcodeInspector.safe.activeApplication - let latestActiveXcode = await xcodeInspector.safe.latestActiveXcode - let previousActiveApplication = xcodeInspector.previousActiveApplication + let activeApp = await xcodeInspector.activeApplication + let latestActiveXcode = await xcodeInspector.latestActiveXcode + let previousActiveApplication = await xcodeInspector.previousActiveApplication await MainActor.run { - let state = store.withState { $0 } - let isChatPanelDetached = state.chatPanelState.isDetached - if let activeApp, activeApp.isXcode { let application = activeApp.appElement /// We need this to hide the windows when Xcode is minimized. let noFocus = application.focusedWindow == nil - windows.sharedPanelWindow.alphaValue = noFocus ? 0 : 1 + windows.sharedPanelWindow.alphaValue = 1 windows.suggestionPanelWindow.alphaValue = noFocus ? 0 : 1 windows.widgetWindow.alphaValue = noFocus ? 0 : 1 windows.toastWindow.alphaValue = noFocus ? 0 : 1 - - if isChatPanelDetached { - windows.chatPanelWindow.isWindowHidden = false - } else { - windows.chatPanelWindow.isWindowHidden = noFocus - } } else if let activeApp, activeApp.isExtensionService { let noFocus = { guard let xcode = latestActiveXcode else { return true } @@ -418,7 +421,7 @@ extension WidgetWindowsController { let previousAppIsXcode = previousActiveApplication?.isXcode ?? false - windows.sharedPanelWindow.alphaValue = noFocus ? 0 : 1 + windows.sharedPanelWindow.alphaValue = 1 windows.suggestionPanelWindow.alphaValue = noFocus ? 0 : 1 windows.widgetWindow.alphaValue = if noFocus { 0 @@ -434,20 +437,11 @@ extension WidgetWindowsController { 0 } windows.toastWindow.alphaValue = noFocus ? 0 : 1 - if isChatPanelDetached { - windows.chatPanelWindow.isWindowHidden = false - } else { - windows.chatPanelWindow.isWindowHidden = noFocus && !windows - .chatPanelWindow.isKeyWindow - } } else { - windows.sharedPanelWindow.alphaValue = 0 + windows.sharedPanelWindow.alphaValue = 1 windows.suggestionPanelWindow.alphaValue = 0 windows.widgetWindow.alphaValue = 0 windows.toastWindow.alphaValue = 0 - if !isChatPanelDetached { - windows.chatPanelWindow.isWindowHidden = true - } } } } @@ -503,6 +497,7 @@ extension WidgetWindowsController { } await adjustChatPanelWindowLevel() + await adjustModificationPanelLevel() } let now = Date() @@ -531,39 +526,49 @@ extension WidgetWindowsController { lastUpdateWindowLocationTime = Date() } + @MainActor + func adjustModificationPanelLevel() async { + let window = windows.sharedPanelWindow + + let latestApp = await xcodeInspector.activeApplication + let latestAppIsXcodeOrExtension = if let latestApp { + latestApp.isXcode || latestApp.isExtensionService + } else { + false + } + + window.setFloatOnTop(latestAppIsXcodeOrExtension) + } + @MainActor func adjustChatPanelWindowLevel() async { + let flowOnTopOption = UserDefaults.shared + .value(for: \.chatPanelFloatOnTopOption) let disableFloatOnTopWhenTheChatPanelIsDetached = UserDefaults.shared .value(for: \.disableFloatOnTopWhenTheChatPanelIsDetached) let window = windows.chatPanelWindow - guard disableFloatOnTopWhenTheChatPanelIsDetached else { - window.setFloatOnTop(true) + + if flowOnTopOption == .never { + window.setFloatOnTop(false) return } let state = store.withState { $0 } let isChatPanelDetached = state.chatPanelState.isDetached - guard isChatPanelDetached else { - window.setFloatOnTop(true) - return - } - let floatOnTopWhenOverlapsXcode = UserDefaults.shared .value(for: \.keepFloatOnTopIfChatPanelAndXcodeOverlaps) - let latestApp = await xcodeInspector.safe.activeApplication + let latestApp = await xcodeInspector.activeApplication let latestAppIsXcodeOrExtension = if let latestApp { latestApp.isXcode || latestApp.isExtensionService } else { false } - if !floatOnTopWhenOverlapsXcode || !latestAppIsXcodeOrExtension { - window.setFloatOnTop(false) - } else { - guard let xcode = await xcodeInspector.safe.latestActiveXcode else { return } + async let overlap: Bool = { @MainActor in + guard let xcode = await xcodeInspector.latestActiveXcode else { return false } let windowElements = xcode.appElement.windows let overlap = windowElements.contains { if let position = $0.position, let size = $0.size { @@ -577,17 +582,43 @@ extension WidgetWindowsController { } return false } + return overlap + }() - window.setFloatOnTop(overlap) + if latestAppIsXcodeOrExtension { + if floatOnTopWhenOverlapsXcode { + let overlap = await overlap + window.setFloatOnTop(overlap) + } else { + if disableFloatOnTopWhenTheChatPanelIsDetached, isChatPanelDetached { + window.setFloatOnTop(false) + } else { + window.setFloatOnTop(true) + } + } + } else { + if floatOnTopWhenOverlapsXcode { + let overlap = await overlap + window.setFloatOnTop(overlap) + } else { + switch flowOnTopOption { + case .onTopWhenXcodeIsActive: + window.setFloatOnTop(false) + case .alwaysOnTop: + window.setFloatOnTop(true) + case .never: + window.setFloatOnTop(false) + } + } } } @MainActor func handleSpaceChange() async { - let activeXcode = await XcodeInspector.shared.safe.activeXcode + let activeXcode = XcodeInspector.shared.activeXcode let xcode = activeXcode?.appElement - + let isXcodeActive = xcode?.isFrontmost ?? false [ @@ -600,7 +631,7 @@ extension WidgetWindowsController { $0.moveToActiveSpace() } } - + if isXcodeActive, !windows.chatPanelWindow.isDetached { windows.chatPanelWindow.moveToActiveSpace() } @@ -673,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 @@ -691,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( @@ -717,10 +746,10 @@ public final class WidgetWindows { defer: false ) it.isReleasedWhenClosed = false - it.isOpaque = false it.backgroundColor = .clear it.level = widgetLevel(2) - it.hasShadow = true + it.hoveringLevel = widgetLevel(2) + it.hasShadow = false it.contentView = NSHostingView( rootView: SharedPanelView( store: store.scope( @@ -735,7 +764,7 @@ public final class WidgetWindows { it.setIsVisible(true) it.canBecomeKeyChecker = { [store] in store.withState { state in - state.panelState.sharedPanelState.content.promptToCode != nil + !state.panelState.sharedPanelState.content.promptToCodeGroup.promptToCodes.isEmpty } } return it @@ -750,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( @@ -782,6 +812,7 @@ public final class WidgetWindows { self?.store.send(.chatPanel(.hideButtonClicked)) } ) + it.hoveringLevel = widgetLevel(1) it.delegate = controller return it }() @@ -846,6 +877,10 @@ class WidgetWindow: CanBecomeKeyWindow { case switchingSpace } + var hoveringLevel: NSWindow.Level = widgetLevel(0) + + override var isFloatingPanel: Bool { true } + var defaultCollectionBehavior: NSWindow.CollectionBehavior { [.fullScreenAuxiliary, .transient] } @@ -867,7 +902,7 @@ class WidgetWindow: CanBecomeKeyWindow { } } } - + func moveToActiveSpace() { let previousState = state state = .switchingSpace @@ -876,11 +911,44 @@ class WidgetWindow: CanBecomeKeyWindow { self.state = previousState } } + + func setFloatOnTop(_ isFloatOnTop: Bool) { + let targetLevel: NSWindow.Level = isFloatOnTop + ? hoveringLevel + : .normal + + if targetLevel != level { + orderFrontRegardless() + level = targetLevel + } + } } func widgetLevel(_ 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) } +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/Core/Sources/XcodeThemeController/XcodeThemeController.swift b/Core/Sources/XcodeThemeController/XcodeThemeController.swift index 5cff0ddd..01118547 100644 --- a/Core/Sources/XcodeThemeController/XcodeThemeController.swift +++ b/Core/Sources/XcodeThemeController/XcodeThemeController.swift @@ -157,8 +157,9 @@ extension XcodeThemeController { } let xcodeURL: URL? = { - // Use the latest running Xcode - if let running = XcodeInspector.shared.latestActiveXcode?.bundleURL { + if let running = NSWorkspace.shared + .urlForApplication(withBundleIdentifier: "com.apple.dt.Xcode") + { return running } // Use the main Xcode.app diff --git a/Core/Tests/ServiceTests/Environment.swift b/Core/Tests/ServiceTests/Environment.swift index ee78f322..13a66210 100644 --- a/Core/Tests/ServiceTests/Environment.swift +++ b/Core/Tests/ServiceTests/Environment.swift @@ -14,6 +14,10 @@ func completion(text: String, range: CursorRange, uuid: String = "") -> CodeSugg } class MockSuggestionService: GitHubCopilotSuggestionServiceType { + func cancelOngoingTask(workDoneToken: String) async { + fatalError() + } + func notifyChangeTextDocument(fileURL: URL, content: String, version: Int) async throws { fatalError() } diff --git a/EditorExtension/AcceptSuggestionCommand.swift b/EditorExtension/AcceptSuggestionCommand.swift index 1556ce74..cbca64ed 100644 --- a/EditorExtension/AcceptSuggestionCommand.swift +++ b/EditorExtension/AcceptSuggestionCommand.swift @@ -31,3 +31,30 @@ class AcceptSuggestionCommand: NSObject, XCSourceEditorCommand, CommandType { } } +class AcceptSuggestionLineCommand: NSObject, XCSourceEditorCommand, CommandType { + var name: String { "Accept Suggestion Line" } + + func perform( + with invocation: XCSourceEditorCommandInvocation, + completionHandler: @escaping (Error?) -> Void + ) { + Task { + do { + try await (Task(timeout: 7) { + let service = try getService() + if let content = try await service.send( + requestBody: ExtensionServiceRequests + .GetSuggestionLineAcceptedCode(editorContent: .init(invocation)) + ) { + invocation.accept(content) + } + completionHandler(nil) + }.value) + } catch is CancellationError { + completionHandler(nil) + } catch { + completionHandler(error) + } + } + } +} 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/EditorExtension/SourceEditorExtension.swift b/EditorExtension/SourceEditorExtension.swift index aedf6bf3..f102f9d4 100644 --- a/EditorExtension/SourceEditorExtension.swift +++ b/EditorExtension/SourceEditorExtension.swift @@ -12,6 +12,7 @@ class SourceEditorExtension: NSObject, XCSourceEditorExtension { [ GetSuggestionsCommand(), AcceptSuggestionCommand(), + AcceptSuggestionLineCommand(), RejectSuggestionCommand(), NextSuggestionCommand(), PreviousSuggestionCommand(), diff --git a/ExtensionService/AppDelegate+Menu.swift b/ExtensionService/AppDelegate+Menu.swift index 4714abb5..9107c97a 100644 --- a/ExtensionService/AppDelegate+Menu.swift +++ b/ExtensionService/AppDelegate+Menu.swift @@ -2,6 +2,8 @@ import AppKit import Foundation import Preferences import XcodeInspector +import Dependencies +import Workspace extension AppDelegate { fileprivate var statusBarMenuIdentifier: NSUserInterfaceItemIdentifier { @@ -95,6 +97,12 @@ extension AppDelegate { action: #selector(reactivateObservationsToXcode), keyEquivalent: "" ) + + let resetWorkspacesItem = NSMenuItem( + title: "Reset workspaces", + action: #selector(destroyWorkspacePool), + keyEquivalent: "" + ) reactivateObservationsItem.target = self @@ -108,6 +116,7 @@ extension AppDelegate { statusBarMenu.addItem(xcodeInspectorDebug) statusBarMenu.addItem(accessibilityAPIPermission) statusBarMenu.addItem(reactivateObservationsItem) + statusBarMenu.addItem(resetWorkspacesItem) statusBarMenu.addItem(quitItem) statusBarMenu.delegate = self @@ -160,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)")) @@ -217,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 @@ -234,7 +252,9 @@ private extension AppDelegate { } @objc func reactivateObservationsToXcode() { - XcodeInspector.shared.reactivateObservationsToXcode() + Task { + await XcodeInspector.shared.reactivateObservationsToXcode() + } } @objc func openExtensionManager() { @@ -248,6 +268,18 @@ private extension AppDelegate { ) } } + + @objc func destroyWorkspacePool() { + @Dependency(\.workspacePool) var workspacePool: WorkspacePool + Task { + 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/ExtensionService/Info.plist b/ExtensionService/Info.plist index 94c867be..9faed878 100644 --- a/ExtensionService/Info.plist +++ b/ExtensionService/Info.plist @@ -12,6 +12,11 @@ $(EXTENSION_BUNDLE_NAME) HOST_APP_NAME $(HOST_APP_NAME) + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + TEAM_ID_PREFIX $(TeamIdentifierPrefix) XPCService 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 e74d5252..c4066a45 100644 --- a/README.md +++ b/README.md @@ -215,7 +215,7 @@ The app can provide real-time code suggestions based on the files you have opene The feature provides two presentation modes: - Nearby Text Cursor: This mode shows suggestions based on the position of the text cursor. -- Floating Widget: This mode shows suggestions next to the circular widget. +- Floating Widget: This mode shows suggestions next to the indicator widget. When using the "Nearby Text Cursor" mode, it is recommended to set the real-time suggestion debounce to 0.1. @@ -251,7 +251,7 @@ The chat knows the following information: There are currently two tabs in the chat panel: one is available shared across Xcode, and the other is only available in the current file. -You can detach the chat panel by simply dragging it away. Once detached, the chat panel will remain visible even if Xcode is inactive. To re-attach it to the widget, click the message bubble button located next to the circular widget. +You can detach the chat panel by simply dragging it away. Once detached, the chat panel will remain visible even if Xcode is inactive. To re-attach it to the widget, click the message bubble button located next to the indicator widget. #### Commands @@ -262,14 +262,14 @@ You can detach the chat panel by simply dragging it away. Once detached, the cha | Shortcut | Description | | :------: | --------------------------------------------------------------------------------------------------- | | `⌘W` | Close the chat tab. | -| `⌘M` | Minimize the chat, you can bring it back with any chat commands or by clicking the circular widget. | +| `⌘M` | Minimize the chat, you can bring it back with any chat commands or by clicking the indicator widget. | | `⇧↩︎` | Add new line. | | `⇧⌘]` | Move to next tab | | `⇧⌘[` | Move to previous tab | #### 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,12 +304,12 @@ 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 -You can create custom commands that run Chat and Modification with personalized prompts. These commands are easily accessible from both the Xcode menu bar and the context menu of the circular widget. There are 3 types of custom commands: +You can create custom commands that run Chat and Modification with personalized prompts. These commands are easily accessible from both the Xcode menu bar and the context menu of the indicator widget. There are 3 types of custom commands: - Modification: Run Modification with the selected code, and update or write the code using the given prompt, if provided. You can provide additional information through the extra system prompt field. - Send Message: Open the chat window and immediately send a message, if provided. You can provide more information through the extra system prompt field. diff --git a/TestPlan.xctestplan b/TestPlan.xctestplan index 8806c7f2..c9ebe525 100644 --- a/TestPlan.xctestplan +++ b/TestPlan.xctestplan @@ -24,86 +24,93 @@ "testTargets" : [ { "target" : { - "containerPath" : "container:Core", - "identifier" : "ServiceTests", - "name" : "ServiceTests" + "containerPath" : "container:Tool", + "identifier" : "SharedUIComponentsTests", + "name" : "SharedUIComponentsTests" } }, { "target" : { - "containerPath" : "container:Core", - "identifier" : "SuggestionWidgetTests", - "name" : "SuggestionWidgetTests" + "containerPath" : "container:Tool", + "identifier" : "ActiveDocumentChatContextCollectorTests", + "name" : "ActiveDocumentChatContextCollectorTests" + } + }, + { + "target" : { + "containerPath" : "container:Tool", + "identifier" : "CodeDiffTests", + "name" : "CodeDiffTests" } }, { "target" : { "containerPath" : "container:Core", - "identifier" : "PromptToCodeServiceTests", - "name" : "PromptToCodeServiceTests" + "identifier" : "ServiceUpdateMigrationTests", + "name" : "ServiceUpdateMigrationTests" } }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "LangChainTests", - "name" : "LangChainTests" + "identifier" : "ASTParserTests", + "name" : "ASTParserTests" } }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "OpenAIServiceTests", - "name" : "OpenAIServiceTests" + "identifier" : "SuggestionProviderTests", + "name" : "SuggestionProviderTests" } }, { "target" : { "containerPath" : "container:Core", - "identifier" : "ChatServiceTests", - "name" : "ChatServiceTests" + "identifier" : "PromptToCodeServiceTests", + "name" : "PromptToCodeServiceTests" } }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "TokenEncoderTests", - "name" : "TokenEncoderTests" + "identifier" : "KeychainTests", + "name" : "KeychainTests" } }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "SharedUIComponentsTests", - "name" : "SharedUIComponentsTests" + "identifier" : "JoinJSONTests", + "name" : "JoinJSONTests" } }, { "target" : { - "containerPath" : "container:Tool", - "identifier" : "ASTParserTests", - "name" : "ASTParserTests" + "containerPath" : "container:Core", + "identifier" : "ServiceTests", + "name" : "ServiceTests" } }, { "target" : { - "containerPath" : "container:Core", - "identifier" : "ServiceUpdateMigrationTests", - "name" : "ServiceUpdateMigrationTests" + "containerPath" : "container:OverlayWindow", + "identifier" : "OverlayWindowTests", + "name" : "OverlayWindowTests" } }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "KeychainTests", - "name" : "KeychainTests" + "identifier" : "SuggestionBasicTests", + "name" : "SuggestionBasicTests" } }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "ActiveDocumentChatContextCollectorTests", - "name" : "ActiveDocumentChatContextCollectorTests" + "identifier" : "LangChainTests", + "name" : "LangChainTests" } }, { @@ -116,36 +123,50 @@ { "target" : { "containerPath" : "container:Tool", - "identifier" : "FocusedCodeFinderTests", - "name" : "FocusedCodeFinderTests" + "identifier" : "OpenAIServiceTests", + "name" : "OpenAIServiceTests" } }, { "target" : { - "containerPath" : "container:Tool", - "identifier" : "XcodeInspectorTests", - "name" : "XcodeInspectorTests" + "containerPath" : "container:Core", + "identifier" : "SuggestionWidgetTests", + "name" : "SuggestionWidgetTests" + } + }, + { + "target" : { + "containerPath" : "container:Core", + "identifier" : "KeyBindingManagerTests", + "name" : "KeyBindingManagerTests" } }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "SuggestionProviderTests", - "name" : "SuggestionProviderTests" + "identifier" : "FocusedCodeFinderTests", + "name" : "FocusedCodeFinderTests" } }, { "target" : { - "containerPath" : "container:Core", - "identifier" : "KeyBindingManagerTests", - "name" : "KeyBindingManagerTests" + "containerPath" : "container:Tool", + "identifier" : "WebSearchServiceTests", + "name" : "WebSearchServiceTests" } }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "SuggestionBasicTests", - "name" : "SuggestionBasicTests" + "identifier" : "XcodeInspectorTests", + "name" : "XcodeInspectorTests" + } + }, + { + "target" : { + "containerPath" : "container:Core", + "identifier" : "ChatServiceTests", + "name" : "ChatServiceTests" } }, { @@ -158,8 +179,8 @@ { "target" : { "containerPath" : "container:Tool", - "identifier" : "CodeDiffTests", - "name" : "CodeDiffTests" + "identifier" : "TokenEncoderTests", + "name" : "TokenEncoderTests" } } ], diff --git a/Tool/Package.swift b/Tool/Package.swift index b5f93686..f303e44c 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -10,7 +10,7 @@ let package = Package( .library(name: "XPCShared", targets: ["XPCShared"]), .library(name: "Terminal", targets: ["Terminal"]), .library(name: "LangChain", targets: ["LangChain"]), - .library(name: "ExternalServices", targets: ["BingSearchService"]), + .library(name: "ExternalServices", targets: ["WebSearchService"]), .library(name: "Preferences", targets: ["Preferences", "Configs"]), .library(name: "Logger", targets: ["Logger"]), .library(name: "OpenAIService", targets: ["OpenAIService"]), @@ -52,7 +52,8 @@ let package = Package( .library(name: "CommandHandler", targets: ["CommandHandler"]), .library(name: "CodeDiff", targets: ["CodeDiff"]), .library(name: "BuiltinExtension", targets: ["BuiltinExtension"]), - .library(name: "BingSearchService", targets: ["BingSearchService"]), + .library(name: "WebSearchService", targets: ["WebSearchService"]), + .library(name: "WebScrapper", targets: ["WebScrapper"]), .library( name: "CustomCommandTemplateProcessor", targets: ["CustomCommandTemplateProcessor"] @@ -81,7 +82,10 @@ let package = Package( url: "https://github.com/intitni/generative-ai-swift", branch: "support-setting-base-url" ), - .package(url: "https://github.com/intitni/CopilotForXcodeKit", branch: "feature/custom-chat-tab"), + .package( + url: "https://github.com/intitni/CopilotForXcodeKit", + branch: "feature/custom-chat-tab" + ), // TreeSitter .package(url: "https://github.com/intitni/SwiftTreeSitter.git", branch: "main"), @@ -104,6 +108,9 @@ let package = Package( .target(name: "ObjectiveCExceptionHandling"), + .target(name: "JoinJSON"), + .testTarget(name: "JoinJSONTests", dependencies: ["JoinJSON"]), + .target(name: "CodeDiff", dependencies: ["SuggestionBasic"]), .testTarget(name: "CodeDiffTests", dependencies: ["CodeDiff"]), @@ -216,6 +223,7 @@ let package = Package( name: "ModificationBasic", dependencies: [ "SuggestionBasic", + "ChatBasic", .product(name: "CodableWrappers", package: "CodableWrappers"), .product( name: "ComposableArchitecture", @@ -223,12 +231,14 @@ let package = Package( ), ] ), + .testTarget(name: "ModificationBasicTests", dependencies: ["ModificationBasic"]), .target( name: "PromptToCodeCustomization", dependencies: [ "ModificationBasic", "SuggestionBasic", + "ChatBasic", .product( name: "ComposableArchitecture", package: "swift-composable-architecture" @@ -236,7 +246,10 @@ let package = Package( ] ), - .target(name: "AXExtension"), + .target( + name: "AXExtension", + dependencies: ["Logger"] + ), .target( name: "AXNotificationStream", @@ -376,7 +389,12 @@ let package = Package( ] ), - .target(name: "BingSearchService"), + .target(name: "WebScrapper", dependencies: [ + .product(name: "SwiftSoup", package: "SwiftSoup"), + ]), + + .target(name: "WebSearchService", dependencies: ["Preferences", "WebScrapper", "Keychain"]), + .testTarget(name: "WebSearchServiceTests", dependencies: ["WebSearchService"]), .target(name: "SuggestionProvider", dependencies: [ "SuggestionBasic", @@ -453,6 +471,8 @@ let package = Package( "Keychain", "BuiltinExtension", "ChatBasic", + "GitHubCopilotService", + "JoinJSON", .product(name: "JSONRPC", package: "JSONRPC"), .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), .product(name: "GoogleGenerativeAI", package: "generative-ai-swift"), @@ -478,10 +498,16 @@ let package = Package( .target( name: "ChatTab", - dependencies: [.product( - name: "ComposableArchitecture", - package: "swift-composable-architecture" - )] + dependencies: [ + "Preferences", + "Configs", + "AIModel", + .product(name: "CodableWrappers", package: "CodableWrappers"), + .product( + name: "ComposableArchitecture", + package: "swift-composable-architecture" + ), + ] ), // MARK: - Chat Context Collector diff --git a/Tool/Sources/AIModel/ChatModel.swift b/Tool/Sources/AIModel/ChatModel.swift index c98658c7..145d0298 100644 --- a/Tool/Sources/AIModel/ChatModel.swift +++ b/Tool/Sources/AIModel/ChatModel.swift @@ -23,6 +23,7 @@ public struct ChatModel: Codable, Equatable, Identifiable { case googleAI case ollama case claude + case gitHubCopilot } public struct Info: Codable, Equatable { @@ -52,13 +53,17 @@ public struct ChatModel: Codable, Equatable, Identifiable { public var enforceMessageOrder: Bool @FallbackDecoding public var supportsMultipartMessageContent: Bool + @FallbackDecoding + public var requiresBeginWithUserMessage: Bool public init( enforceMessageOrder: Bool = false, - supportsMultipartMessageContent: Bool = true + supportsMultipartMessageContent: Bool = true, + requiresBeginWithUserMessage: Bool = false ) { self.enforceMessageOrder = enforceMessageOrder self.supportsMultipartMessageContent = supportsMultipartMessageContent + self.requiresBeginWithUserMessage = requiresBeginWithUserMessage } } @@ -89,6 +94,14 @@ public struct ChatModel: Codable, Equatable, Identifiable { self.headers = headers } } + + public struct CustomBodyInfo: Codable, Equatable { + public var jsonBody: String + + public init(jsonBody: String = "") { + self.jsonBody = jsonBody + } + } @FallbackDecoding public var apiKeyName: String @@ -117,6 +130,8 @@ public struct ChatModel: Codable, Equatable, Identifiable { public var openAICompatibleInfo: OpenAICompatibleInfo @FallbackDecoding public var customHeaderInfo: CustomHeaderInfo + @FallbackDecoding + public var customBodyInfo: CustomBodyInfo public init( apiKeyName: String = "", @@ -131,7 +146,8 @@ public struct ChatModel: Codable, Equatable, Identifiable { ollamaInfo: OllamaInfo = OllamaInfo(), googleGenerativeAIInfo: GoogleGenerativeAIInfo = GoogleGenerativeAIInfo(), openAICompatibleInfo: OpenAICompatibleInfo = OpenAICompatibleInfo(), - customHeaderInfo: CustomHeaderInfo = CustomHeaderInfo() + customHeaderInfo: CustomHeaderInfo = CustomHeaderInfo(), + customBodyInfo: CustomBodyInfo = CustomBodyInfo() ) { self.apiKeyName = apiKeyName self.baseURL = baseURL @@ -146,6 +162,7 @@ public struct ChatModel: Codable, Equatable, Identifiable { self.googleGenerativeAIInfo = googleGenerativeAIInfo self.openAICompatibleInfo = openAICompatibleInfo self.customHeaderInfo = customHeaderInfo + self.customBodyInfo = customBodyInfo } } @@ -178,6 +195,8 @@ public struct ChatModel: Codable, Equatable, Identifiable { let baseURL = info.baseURL if baseURL.isEmpty { return "https://api.anthropic.com/v1/messages" } return "\(baseURL)/v1/messages" + case .gitHubCopilot: + return "https://api.githubcopilot.com/chat/completions" } } } @@ -210,6 +229,10 @@ public struct EmptyChatModelCustomHeaderInfo: FallbackValueProvider { public static var defaultValue: ChatModel.Info.CustomHeaderInfo { .init() } } +public struct EmptyChatModelCustomBodyInfo: FallbackValueProvider { + public static var defaultValue: ChatModel.Info.CustomBodyInfo { .init() } +} + public struct EmptyTrue: FallbackValueProvider { public static var defaultValue: Bool { true } } diff --git a/Tool/Sources/AIModel/EmbeddingModel.swift b/Tool/Sources/AIModel/EmbeddingModel.swift index ad77cd9a..4e192dda 100644 --- a/Tool/Sources/AIModel/EmbeddingModel.swift +++ b/Tool/Sources/AIModel/EmbeddingModel.swift @@ -21,6 +21,7 @@ public struct EmbeddingModel: Codable, Equatable, Identifiable { case azureOpenAI case openAICompatible case ollama + case gitHubCopilot } public struct Info: Codable, Equatable { @@ -92,6 +93,8 @@ public struct EmbeddingModel: Codable, Equatable, Identifiable { let baseURL = info.baseURL if baseURL.isEmpty { return "http://localhost:11434/api/embeddings" } return "\(baseURL)/api/embeddings" + case .gitHubCopilot: + return "https://api.githubcopilot.com/embeddings" } } } diff --git a/Tool/Sources/AXExtension/AXUIElement.swift b/Tool/Sources/AXExtension/AXUIElement.swift index ba09b05d..e54bfaff 100644 --- a/Tool/Sources/AXExtension/AXUIElement.swift +++ b/Tool/Sources/AXExtension/AXUIElement.swift @@ -1,9 +1,6 @@ import AppKit import Foundation import Logger -#if DEBUG -import IssueReporting -#endif // MARK: - State @@ -61,7 +58,9 @@ public extension AXUIElement { } var isSourceEditor: Bool { - description == "Source Editor" + if !(description == "Source Editor" && role != kAXUnknownRole) { return false } + if let _ = firstParent(where: { $0.identifier == "editor context" }) { return true } + return false } var selectedTextRange: ClosedRange? { @@ -85,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 @@ -139,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 @@ -188,7 +225,7 @@ public extension AXUIElement { ) -> AXUIElement? { #if DEBUG if depth >= 50 { - reportIssue("AXUIElement.child: Exceeding recommended depth.") + fatalError("AXUIElement.child: Exceeding recommended depth.") } #endif @@ -225,10 +262,10 @@ public extension AXUIElement { func children(depth: Int = 0, where match: (AXUIElement) -> Bool) -> [AXUIElement] { #if DEBUG if depth >= 50 { - reportIssue("AXUIElement.children: Exceeding recommended depth.") + fatalError("AXUIElement.children: Exceeding recommended depth.") } #endif - + var all = [AXUIElement]() for child in children { if match(child) { all.append(child) } @@ -245,10 +282,18 @@ 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 { - reportIssue("AXUIElement.firstChild: Exceeding recommended depth.") + if depth > maxDepth { + fatalError("AXUIElement.firstChild: Exceeding recommended depth.") + } + #else + if depth > maxDepth { + return nil } #endif for child in children { @@ -276,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 } @@ -290,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 } } @@ -317,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) + } } } @@ -352,5 +442,7 @@ public extension AXUIElement { } } +extension AXError: @retroactive _BridgedNSError {} +extension AXError: @retroactive _ObjectiveCBridgeableError {} extension AXError: @retroactive Error {} 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/AppActivator/AppActivator.swift b/Tool/Sources/AppActivator/AppActivator.swift index 8d31b32c..2011360a 100644 --- a/Tool/Sources/AppActivator/AppActivator.swift +++ b/Tool/Sources/AppActivator/AppActivator.swift @@ -34,7 +34,7 @@ public extension NSWorkspace { static func activatePreviousActiveApp(delay: TimeInterval = 0.2) { Task { @MainActor in - guard let app = await XcodeInspector.shared.safe.previousActiveApplication + guard let app = XcodeInspector.shared.previousActiveApplication else { return } try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) activateApp(app) @@ -43,7 +43,7 @@ public extension NSWorkspace { static func activatePreviousActiveXcode(delay: TimeInterval = 0.2) { Task { @MainActor in - guard let app = await XcodeInspector.shared.safe.latestActiveXcode else { return } + guard let app = XcodeInspector.shared.latestActiveXcode else { return } try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) activateApp(app) } diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtensionManager.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionManager.swift index 3d78fab2..86832df5 100644 --- a/Tool/Sources/BuiltinExtension/BuiltinExtensionManager.swift +++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionManager.swift @@ -7,14 +7,19 @@ public final class BuiltinExtensionManager { public static let shared: BuiltinExtensionManager = .init() public private(set) var extensions: [any BuiltinExtension] = [] - private var cancellable: Set = [] - init() { - XcodeInspector.shared.$activeApplication.sink { [weak self] app in - if let app, app.isXcode || app.isExtensionService { - self?.checkAppConfiguration() + Task { [weak self] in + let notifications = NotificationCenter.default + .notifications(named: .activeApplicationDidChange) + for await _ in notifications { + guard let self else { return } + if let app = await XcodeInspector.shared.activeApplication, + app.isXcode || app.isExtensionService + { + self.checkAppConfiguration() + } } - }.store(in: &cancellable) + } } public func setupExtensions(_ extensions: [any BuiltinExtension]) { diff --git a/Tool/Sources/ChatBasic/ChatAgent.swift b/Tool/Sources/ChatBasic/ChatAgent.swift index 7f464881..1b6b835d 100644 --- a/Tool/Sources/ChatBasic/ChatAgent.swift +++ b/Tool/Sources/ChatBasic/ChatAgent.swift @@ -4,7 +4,7 @@ public enum ChatAgentResponse { public enum Content { case text(String) } - + public enum ActionResult { case success(String) case failure(String) @@ -24,6 +24,8 @@ public enum ChatAgentResponse { case references([ChatMessage.Reference]) /// End the current message. The next contents will be sent as a new message. case startNewMessage + /// Reasoning + case reasoning(String) } public struct ChatAgentRequest { @@ -31,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 4b19471d..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 {} @@ -21,7 +48,7 @@ public protocol ChatGPTFunction { typealias NoArguments = NoChatGPTFunctionArguments associatedtype Arguments: Decodable associatedtype Result: ChatGPTFunctionResult - typealias ReportProgress = (String) async -> Void + typealias ReportProgress = @Sendable (String) async -> Void /// The name of this function. /// May contain a-z, A-Z, 0-9, and underscores, with a maximum length of 64 characters. @@ -89,7 +116,7 @@ public extension ChatGPTArgumentsCollectingFunction { } } -public struct ChatGPTFunctionSchema: Codable, Equatable { +public struct ChatGPTFunctionSchema: Codable, Equatable, Sendable { public var name: String public var description: String public var parameters: JSONSchemaValue diff --git a/Tool/Sources/ChatBasic/ChatMessage.swift b/Tool/Sources/ChatBasic/ChatMessage.swift index a0f7d432..ab5f04a4 100644 --- a/Tool/Sources/ChatBasic/ChatMessage.swift +++ b/Tool/Sources/ChatBasic/ChatMessage.swift @@ -1,19 +1,21 @@ -import CodableWrappers +@preconcurrency import CodableWrappers import Foundation /// A chat message that can be sent or received. -public struct ChatMessage: Equatable, Codable { +public struct ChatMessage: Equatable, Codable, Sendable { public typealias ID = String /// The role of a message. - public enum Role: String, Codable, Equatable { + public enum Role: String, Codable, Equatable, Sendable { case system case user case assistant + // There is no `tool` role + // because tool calls and results are stored in the assistant messages. } /// A function call that can be made by the bot. - public struct FunctionCall: Codable, Equatable { + public struct FunctionCall: Codable, Equatable, Sendable { /// The name of the function. public var name: String /// Arguments in the format of a JSON string. @@ -25,7 +27,7 @@ public struct ChatMessage: Equatable, Codable { } /// A tool call that can be made by the bot. - public struct ToolCall: Codable, Equatable, Identifiable { + public struct ToolCall: Codable, Equatable, Identifiable, Sendable { public var id: String /// The type of tool call. public var type: String @@ -47,7 +49,7 @@ public struct ChatMessage: Equatable, Codable { } /// The response of a tool call - public struct ToolCallResponse: Codable, Equatable { + public struct ToolCallResponse: Codable, Equatable, Sendable { /// The content of the response. public var content: String /// The summary of the response to display in UI. @@ -59,10 +61,10 @@ public struct ChatMessage: Equatable, Codable { } /// A reference to include in a chat message. - public struct Reference: Codable, Equatable { + public struct Reference: Codable, Equatable, Identifiable, Sendable { /// The kind of reference. - public enum Kind: Codable, Equatable { - public enum Symbol: String, Codable { + public enum Kind: Codable, Equatable, Sendable { + public enum Symbol: String, Codable, Sendable { case `class` case `struct` case `enum` @@ -75,6 +77,7 @@ public struct ChatMessage: Equatable, Codable { case function case method } + /// Code symbol. case symbol(Symbol, uri: String, startLine: Int?, endLine: Int?) /// Some text. @@ -89,6 +92,8 @@ public struct ChatMessage: Equatable, Codable { case error } + @FallbackDecoding + public var id: String /// The title of the reference. public var title: String /// The content of the reference. @@ -98,16 +103,37 @@ public struct ChatMessage: Equatable, Codable { public var kind: Kind public init( + id: String = UUID().uuidString, title: String, content: String, kind: Kind ) { + self.id = id self.title = title self.content = content self.kind = kind } } + public struct Image: Equatable, Sendable, Codable { + public enum Format: String, Sendable, Codable { + case png = "image/png" + case jpeg = "image/jpeg" + case gif = "image/gif" + } + + public var base64EncodedData: String + public var format: Format + public var urlString: String { + "data:\(format.rawValue);base64,\(base64EncodedData)" + } + + public init(base64EncodedData: String, format: Format) { + self.base64EncodedData = base64EncodedData + self.format = format + } + } + /// The role of a message. @FallbackDecoding public var role: Role @@ -132,10 +158,10 @@ public struct ChatMessage: Equatable, Codable { /// The id of the message. public var id: ID - + /// The id of the sender of the message. public var senderId: String? - + /// The id of the message that this message is a response to. public var responseTo: ID? @@ -145,7 +171,11 @@ public struct ChatMessage: Equatable, Codable { /// The references of this message. @FallbackDecoding> public var references: [Reference] - + + /// The images associated with this message. + @FallbackDecoding> + public var images: [Image] + /// Cache the message in the prompt if possible. public var cacheIfPossible: Bool @@ -168,6 +198,7 @@ public struct ChatMessage: Equatable, Codable { summary: String? = nil, tokenCount: Int? = nil, references: [Reference] = [], + images: [Image] = [], cacheIfPossible: Bool = false ) { self.role = role @@ -180,15 +211,20 @@ public struct ChatMessage: Equatable, Codable { self.id = id tokensCount = tokenCount self.references = references + self.images = images self.cacheIfPossible = cacheIfPossible } } -public struct ReferenceKindFallback: FallbackValueProvider { +public struct ReferenceKindFallback: FallbackValueProvider, Sendable { public static var defaultValue: ChatMessage.Reference.Kind { .other(kind: "Unknown") } } -public struct ChatMessageRoleFallback: FallbackValueProvider { +public struct ReferenceIDFallback: FallbackValueProvider, Sendable { + public static var defaultValue: String { UUID().uuidString } +} + +public struct ChatMessageRoleFallback: FallbackValueProvider, Sendable { public static var defaultValue: ChatMessage.Role { .user } } diff --git a/Tool/Sources/ChatBasic/ChatPlugin.swift b/Tool/Sources/ChatBasic/ChatPlugin.swift index 1e7eb576..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] @@ -19,7 +19,13 @@ public protocol ChatPlugin { static var command: String { get } static var name: String { get } static var description: String { get } - func send(_ request: Request) async -> AsyncThrowingStream + // In this method, the plugin is able to send more complicated response. It also enables it to + // perform special tasks like starting a new message or reporting progress. + func sendForComplicatedResponse( + _ request: Request + ) async -> AsyncThrowingStream + // This method allows the plugin to respond a stream of text content only. + func sendForTextResponse(_ request: Request) async -> AsyncThrowingStream func formatContent(_ content: Response.Content) -> Response.Content init() } @@ -28,5 +34,26 @@ public extension ChatPlugin { func formatContent(_ content: Response.Content) -> Response.Content { return content } + + func sendForComplicatedResponse( + _ request: Request + ) async -> AsyncThrowingStream { + let textStream = await sendForTextResponse(request) + return AsyncThrowingStream { continuation in + let task = Task { + do { + for try await text in textStream { + continuation.yield(Response.content(.text(text))) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + + continuation.onTermination = { _ in + task.cancel() + } + } + } } - diff --git a/Tool/Sources/ChatContextCollector/ChatContextCollector.swift b/Tool/Sources/ChatContextCollector/ChatContextCollector.swift index 3e45ed63..82d576c3 100644 --- a/Tool/Sources/ChatContextCollector/ChatContextCollector.swift +++ b/Tool/Sources/ChatContextCollector/ChatContextCollector.swift @@ -4,7 +4,7 @@ import OpenAIService import Parsing public struct ChatContext { - public enum Scope: String, Equatable, CaseIterable, Codable { + public enum Scope: String, Equatable, CaseIterable, Codable, Sendable { case file case code case sense @@ -12,7 +12,7 @@ public struct ChatContext { case web } - public struct RetrievedContent { + public struct RetrievedContent: Sendable { public var document: ChatMessage.Reference public var priority: Int 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/ChatTab/CodeiumChatBrowser.swift b/Tool/Sources/CodeiumService/ChatTab/CodeiumChatBrowser.swift index 46ec9e51..5d7fab76 100644 --- a/Tool/Sources/CodeiumService/ChatTab/CodeiumChatBrowser.swift +++ b/Tool/Sources/CodeiumService/ChatTab/CodeiumChatBrowser.swift @@ -61,7 +61,7 @@ struct CodeiumChatBrowser { case .loadCurrentWorkspace: return .run { send in - guard let workspaceURL = await XcodeInspector.shared.safe.activeWorkspaceURL + guard let workspaceURL = await XcodeInspector.shared.activeWorkspaceURL else { await send(.presentError("Can't find workspace.")) return diff --git a/Tool/Sources/CodeiumService/LanguageServer/CodeiumInstallationManager.swift b/Tool/Sources/CodeiumService/LanguageServer/CodeiumInstallationManager.swift index 13e430e6..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.28.3" + static let latestSupportedVersion = "1.48.2" static let minimumSupportedVersion = "1.20.0" public init() {} diff --git a/Tool/Sources/CodeiumService/LanguageServer/CodeiumLanguageServer.swift b/Tool/Sources/CodeiumService/LanguageServer/CodeiumLanguageServer.swift index c3f83118..051994b9 100644 --- a/Tool/Sources/CodeiumService/LanguageServer/CodeiumLanguageServer.swift +++ b/Tool/Sources/CodeiumService/LanguageServer/CodeiumLanguageServer.swift @@ -387,7 +387,7 @@ class WorkspaceParser: NSObject, XMLParserDelegate { } public func getProjectPaths() async -> [String] { - guard let workspaceURL = await XcodeInspector.shared.safe.realtimeActiveWorkspaceURL else { + guard let workspaceURL = XcodeInspector.shared.realtimeActiveWorkspaceURL else { return [] } diff --git a/Tool/Sources/CodeiumService/Services/CodeiumService.swift b/Tool/Sources/CodeiumService/Services/CodeiumService.swift index 33cb8c0d..046d2df2 100644 --- a/Tool/Sources/CodeiumService/Services/CodeiumService.swift +++ b/Tool/Sources/CodeiumService/Services/CodeiumService.swift @@ -208,7 +208,7 @@ extension CodeiumService { } throw E() } - var ideVersion = await XcodeInspector.shared.safe.latestActiveXcode?.version + var ideVersion = await XcodeInspector.shared.latestActiveXcode?.version ?? fallbackXcodeVersion let versionNumberSegmentCount = ideVersion.split(separator: ".").count if versionNumberSegmentCount == 2 { diff --git a/Tool/Sources/CommandHandler/CommandHandler.swift b/Tool/Sources/CommandHandler/CommandHandler.swift index ab19091e..f5067668 100644 --- a/Tool/Sources/CommandHandler/CommandHandler.swift +++ b/Tool/Sources/CommandHandler/CommandHandler.swift @@ -16,6 +16,7 @@ public protocol CommandHandler { func presentNextSuggestion() async func rejectSuggestions() async func acceptSuggestion() async + func acceptActiveSuggestionLineInGroup(atIndex index: Int?) async func dismissSuggestion() async func generateRealtimeSuggestions(sourceEditor: SourceEditor?) async @@ -39,7 +40,16 @@ public protocol CommandHandler { // MARK: Others - func presentFile(at fileURL: URL, line: Int) async + func presentFile(at fileURL: URL, line: Int?) async + + func presentFile(at fileURL: URL) async +} + +public extension CommandHandler { + /// Default implementation for `presentFile(at:line:)`. + func presentFile(at fileURL: URL) async { + await presentFile(at: fileURL, line: nil) + } } public struct CommandHandlerDependencyKey: DependencyKey { @@ -84,6 +94,10 @@ public final class UniversalCommandHandler: CommandHandler { public func acceptSuggestion() async { await commandHandler.acceptSuggestion() } + + public func acceptActiveSuggestionLineInGroup(atIndex index: Int?) async { + await commandHandler.acceptActiveSuggestionLineInGroup(atIndex: index) + } public func dismissSuggestion() async { await commandHandler.dismissSuggestion() @@ -117,7 +131,7 @@ public final class UniversalCommandHandler: CommandHandler { commandHandler.toast(string, as: type) } - public func presentFile(at fileURL: URL, line: Int) async { + public func presentFile(at fileURL: URL, line: Int?) async { await commandHandler.presentFile(at: fileURL, line: line) } } @@ -142,6 +156,10 @@ struct NOOPCommandHandler: CommandHandler { func acceptSuggestion() async { print("accept suggestion") } + + func acceptActiveSuggestionLineInGroup(atIndex index: Int?) async { + print("accept active suggestion line in group at index \(String(describing: index))") + } func dismissSuggestion() async { print("dismiss suggestion") @@ -175,7 +193,7 @@ struct NOOPCommandHandler: CommandHandler { print("toast") } - func presentFile(at fileURL: URL, line: Int) async { + func presentFile(at fileURL: URL, line: Int?) async { print("present file") } } 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 2a54d320..891c8301 100644 --- a/Tool/Sources/CustomCommandTemplateProcessor/CustomCommandTemplateProcessor.swift +++ b/Tool/Sources/CustomCommandTemplateProcessor/CustomCommandTemplateProcessor.swift @@ -39,8 +39,8 @@ public struct CustomCommandTemplateProcessor { } func getEditorInformation() async -> EditorInformation { - let editorContent = await XcodeInspector.shared.safe.focusedEditor?.getContent() - let documentURL = await XcodeInspector.shared.safe.activeDocumentURL + let editorContent = await XcodeInspector.shared.latestFocusedEditor?.getContent() + let documentURL = await XcodeInspector.shared.activeDocumentURL let language = documentURL.map(languageIdentifierFromFileURL) ?? .plaintext return .init( 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/FocusedCodeFinder/ActiveDocumentContext.swift b/Tool/Sources/FocusedCodeFinder/ActiveDocumentContext.swift index daa20e6b..817fe704 100644 --- a/Tool/Sources/FocusedCodeFinder/ActiveDocumentContext.swift +++ b/Tool/Sources/FocusedCodeFinder/ActiveDocumentContext.swift @@ -1,7 +1,7 @@ import Foundation import SuggestionBasic -public struct ActiveDocumentContext { +public struct ActiveDocumentContext: Sendable { public var documentURL: URL public var relativePath: String public var language: CodeLanguage @@ -13,8 +13,8 @@ public struct ActiveDocumentContext { public var imports: [String] public var includes: [String] - public struct FocusedContext { - public struct Context: Equatable { + public struct FocusedContext: Sendable { + public struct Context: Equatable, Sendable { public var signature: String public var name: String public var range: CursorRange diff --git a/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift index a556e8ff..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) } @@ -139,7 +153,6 @@ protocol ServiceLocatorType { class ServiceLocator: ServiceLocatorType { let workspacePool: WorkspacePool - init(workspacePool: WorkspacePool) { self.workspacePool = workspacePool } @@ -152,3 +165,168 @@ class ServiceLocator: ServiceLocatorType { } } +extension GitHubCopilotExtension { + public struct Token: Codable { +// let codesearch: Bool + public let individual: Bool + public let endpoints: Endpoints + public let chat_enabled: Bool +// public let sku: String +// public let copilotignore_enabled: Bool +// public let limited_user_quotas: String? +// public let tracking_id: String +// public let xcode: Bool +// public let limited_user_reset_date: String? +// public let telemetry: String +// public let prompt_8k: Bool + public let token: String +// public let nes_enabled: Bool +// public let vsc_electron_fetcher_v2: Bool +// public let code_review_enabled: Bool +// public let annotations_enabled: Bool +// public let chat_jetbrains_enabled: Bool +// public let xcode_chat: Bool +// public let refresh_in: Int +// public let snippy_load_test_enabled: Bool +// public let trigger_completion_after_accept: Bool + public let expires_at: Int +// public let public_suggestions: String +// public let code_quote_enabled: Bool + + public struct Endpoints: Codable { + public let api: String + public let proxy: String + public let telemetry: String +// public let origin-tracker: String + } + } + + struct AuthInfo: Codable { + public let user: String + public let oauth_token: String + public let githubAppId: String + } + + static var authInfo: AuthInfo? { + guard let urls = try? GitHubCopilotBaseService.createFoldersIfNeeded() + else { return nil } + let path = urls.supportURL + .appendingPathComponent("undefined") + .appendingPathComponent(".config") + .appendingPathComponent("github-copilot") + .appendingPathComponent("apps.json").path + guard FileManager.default.fileExists(atPath: path) else { return nil } + + do { + let data = try Data(contentsOf: URL(fileURLWithPath: path)) + let json = try JSONSerialization + .jsonObject(with: data, options: []) as? [String: [String: String]] + guard let firstEntry = json?.values.first else { return nil } + let jsonData = try JSONSerialization.data(withJSONObject: firstEntry, options: []) + return try JSONDecoder().decode(AuthInfo.self, from: jsonData) + } catch { + Logger.gitHubCopilot.error(error.localizedDescription) + return nil + } + } + + @MainActor + static var cachedToken: Token? + + public static func fetchToken() async throws -> Token { + guard let authToken = authInfo?.oauth_token + else { throw GitHubCopilotError.notLoggedIn } + + let oldToken = await MainActor.run { cachedToken } + if let oldToken { + let expiresAt = Date(timeIntervalSince1970: TimeInterval(oldToken.expires_at)) + if expiresAt > Date() { + return oldToken + } + } + + let url = URL(string: "https://api.github.com/copilot_internal/v2/token")! + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("token \(authToken)", forHTTPHeaderField: "authorization") + request.setValue("unknown-editor/0", forHTTPHeaderField: "editor-version") + request.setValue("unknown-editor-plugin/0", forHTTPHeaderField: "editor-plugin-version") + request.setValue("1.236.0", forHTTPHeaderField: "copilot-language-server-version") + request.setValue("GithubCopilot/1.236.0", forHTTPHeaderField: "user-agent") + request.setValue("*/*", forHTTPHeaderField: "accept") + request.setValue("gzip,deflate,br", forHTTPHeaderField: "accept-encoding") + + do { + let (data, _) = try await URLSession.shared.data(for: request) + if let jsonString = String(data: data, encoding: .utf8) { + print(jsonString) + } + let newToken = try JSONDecoder().decode(Token.self, from: data) + await MainActor.run { cachedToken = newToken } + return newToken + } catch { + Logger.service.error(error.localizedDescription) + throw error + } + } + + public static func fetchLLMModels() async throws -> [GitHubCopilotLLMModel] { + let token = try await GitHubCopilotExtension.fetchToken() + guard let endpoint = URL(string: token.endpoints.api + "/models") else { + throw CancellationError() + } + var request = URLRequest(url: endpoint) + 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") + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let response = response as? HTTPURLResponse else { + throw CancellationError() + } + + guard response.statusCode == 200 else { + throw CancellationError() + } + + struct Model: Decodable { + struct Limit: Decodable { + var max_context_window_tokens: Int + } + + struct Capability: Decodable { + var type: String? + var family: String? + var limit: Limit? + } + + var id: String + var capabilities: Capability + } + + struct Body: Decodable { + var data: [Model] + } + + let models = try JSONDecoder().decode(Body.self, from: data) + .data + .filter { + $0.capabilities.type == "chat" + } + .map { + GitHubCopilotLLMModel( + modelId: $0.id, + familyName: $0.capabilities.family ?? "", + contextWindow: $0.capabilities.limit?.max_context_window_tokens ?? 0 + ) + } + return models + } +} + 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/CopilotLocalProcessServer.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift index 3f79212e..817a6827 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift @@ -141,7 +141,7 @@ extension CopilotLocalProcessServer: LanguageServerProtocol.Server { /// Cancel ongoing completion requests. public func cancelOngoingTasks() async { - guard let server = wrappedServer, process.isRunning else { + guard let _ = wrappedServer, process.isRunning else { return } @@ -360,6 +360,8 @@ final class ServerNotificationHandler { Logger.gitHubCopilot .info("\(anyNotification.method): \(debugDescription)") } + case "didChangeStatus": + Logger.gitHubCopilot.info("Did change status: \(debugDescription)") default: throw ServerError.handlerUnavailable(methodName) } diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotInstallationManager.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotInstallationManager.swift index 640a09a3..edf59a50 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotInstallationManager.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotInstallationManager.swift @@ -6,12 +6,15 @@ public struct GitHubCopilotInstallationManager { public private(set) static var isInstalling = false static var downloadURL: URL { - let commitHash = "87038123804796ca7af20d1b71c3428d858a9124" + let commitHash = "f89e977c87180519ba3b942200e3d05b17b1e2fc" let link = "https://github.com/github/copilot.vim/archive/\(commitHash).zip" return URL(string: link)! } - static let latestSupportedVersion = "1.41.0" + /// 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.57.0" static let minimumSupportedVersion = "1.32.0" public init() {} @@ -42,11 +45,23 @@ public struct GitHubCopilotInstallationManager { case .orderedAscending: switch version.compare(Self.minimumSupportedVersion) { case .orderedAscending: - return .outdated(current: version, latest: Self.latestSupportedVersion, mandatory: true) + return .outdated( + current: version, + latest: Self.latestSupportedVersion, + mandatory: true + ) case .orderedSame: - return .outdated(current: version, latest: Self.latestSupportedVersion, mandatory: false) + return .outdated( + current: version, + latest: Self.latestSupportedVersion, + mandatory: false + ) case .orderedDescending: - return .outdated(current: version, latest: Self.latestSupportedVersion, mandatory: false) + return .outdated( + current: version, + latest: Self.latestSupportedVersion, + mandatory: false + ) } case .orderedSame: return .installed(version) @@ -136,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/GitHubCopilotRequest.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift index 87219683..d5681c1e 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift @@ -57,7 +57,7 @@ enum GitHubCopilotChatSource: String, Codable { enum GitHubCopilotRequest { struct SetEditorInfo: GitHubCopilotRequestType { let xcodeVersion: String - + struct Response: Codable {} var networkProxy: JSONValue? { @@ -143,7 +143,7 @@ enum GitHubCopilotRequest { var dict: [String: JSONValue] = [ "editorInfo": pretendToBeVSCode ? .hash([ "name": "vscode", - "version": "1.89.1", + "version": "1.99.3", ]) : .hash([ "name": "Xcode", "version": .string(xcodeVersion), @@ -351,32 +351,48 @@ enum GitHubCopilotRequest { } struct RequestBody: Codable { - var workDoneToken: String - var turns: [Turn]; struct Turn: Codable { - var request: String - var response: String? + public struct Reference: Codable, Equatable, Hashable { + public var type: String = "file" + public let uri: String + public let position: Position? + public let visibleRange: SuggestionBasic.CursorRange? + public let selection: SuggestionBasic.CursorRange? + public let openedAt: String? + public let activeAt: String? } - var capabilities: Capabilities; struct Capabilities: Codable { - var allSkills: Bool? - var skills: [String] + enum ConversationSource: String, Codable { + case panel, inline } - var options: [String: String]? - var doc: GitHubCopilotDoc? - var computeSuggestions: Bool? - var references: [Reference]?; struct Reference: Codable { - var uri: String - var position: Position? - var visibleRange: CursorRange? - var selectionRange: CursorRange? - var openedAt: Date? - var activatedAt: Date? + enum ConversationMode: String, Codable { + case agent = "Agent" } - var source: GitHubCopilotChatSource? // inline or panel + struct ConversationTurn: Codable { + var request: String + var response: String? + var turnId: String? + } + + var workDoneToken: String + var turns: [ConversationTurn] + var capabilities: Capabilities + var textDocument: GitHubCopilotDoc? + var references: [Reference]? + var computeSuggestions: Bool? + var source: ConversationSource? var workspaceFolder: String? + var workspaceFolders: [WorkspaceFolder]? + var ignoredSkills: [String]? + var model: String? + var chatMode: ConversationMode? var userLanguage: String? + + struct Capabilities: Codable { + var skills: [String] + var allSkills: Bool? + } } let requestBody: RequestBody @@ -395,24 +411,13 @@ enum GitHubCopilotRequest { var workDoneToken: String var conversationId: String var message: String - var followUp: FollowUp?; struct FollowUp: Codable { - var id: String - var type: String - } - - var options: [String: String]? - var doc: GitHubCopilotDoc? - var computeSuggestions: Bool? - var references: [Reference]?; struct Reference: Codable { - var uri: String - var position: Position? - var visibleRange: CursorRange? - var selectionRange: CursorRange? - var openedAt: Date? - var activatedAt: Date? - } - + var textDocument: GitHubCopilotDoc? + var ignoredSkills: [String]? + var references: [ConversationCreate.RequestBody.Reference]? + var model: String? var workspaceFolder: String? + var workspaceFolders: [WorkspaceFolder]? + var chatMode: String? } let requestBody: RequestBody @@ -459,5 +464,42 @@ enum GitHubCopilotRequest { return .custom("conversation/destroy", dict) } } + + struct CopilotModels: GitHubCopilotRequestType { + typealias Response = [GitHubCopilotModel] + + var request: ClientRequest { + .custom("copilot/models", .hash([:])) + } + } +} + +public struct GitHubCopilotModel: Codable, Equatable { + public let modelFamily: String + public let modelName: String + public let id: String +// public let modelPolicy: CopilotModelPolicy? + public let scopes: [GitHubCopilotPromptTemplateScope] + public let preview: Bool + public let isChatDefault: Bool + public let isChatFallback: Bool +// public let capabilities: CopilotModelCapabilities +// public let billing: CopilotModelBilling? +} + +public struct GitHubCopilotLLMModel: Equatable, Decodable, Identifiable { + public var id: String { modelId } + public var modelId: String + public var familyName: String + public var contextWindow: Int +} + +public enum GitHubCopilotPromptTemplateScope: String, Codable, Equatable { + case chatPanel = "chat-panel" + case editPanel = "edit-panel" + case agentPanel = "agent-panel" + case editor + case inline + case completion } diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift index 590cbbe4..486ddd92 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift @@ -1,6 +1,7 @@ import AppKit import enum CopilotForXcodeKit.SuggestionServiceError import Foundation +import JSONRPC import LanguageClient import LanguageServerProtocol import Logger @@ -53,6 +54,7 @@ extension GitHubCopilotLSP { } enum GitHubCopilotError: Error, LocalizedError { + case notLoggedIn case languageServerNotInstalled case languageServerError(ServerError) case failedToInstallStartScript @@ -60,6 +62,8 @@ enum GitHubCopilotError: Error, LocalizedError { var errorDescription: String? { switch self { + case .notLoggedIn: + return "Not logged in." case .languageServerNotInstalled: return "Language server is not installed." case .failedToInstallStartScript: @@ -79,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: @@ -130,6 +134,9 @@ public class GitHubCopilotBaseService { let urls = try GitHubCopilotBaseService.createFoldersIfNeeded() let executionParams: Process.ExecutionParameters let runner = UserDefaults.shared.value(for: \.runNodeWith) +// let watchedFiles = JSONValue( +// booleanLiteral: projectRootURL.path == "/" ? false : true +// ) guard let agentJSURL = { () -> URL? in let languageServerDotJS = urls.executableURL @@ -241,6 +248,8 @@ public class GitHubCopilotBaseService { experimental: nil ) + let pretendToBeVSCode = UserDefaults.shared + .value(for: \.gitHubCopilotPretendIDEToBeVSCode) return InitializeParams( processId: Int(ProcessInfo.processInfo.processIdentifier), clientInfo: .init( @@ -251,11 +260,28 @@ public class GitHubCopilotBaseService { locale: nil, rootPath: projectRootURL.path, rootUri: projectRootURL.path, - initializationOptions: nil, + initializationOptions: [ + "editorInfo": pretendToBeVSCode ? .hash([ + "name": "vscode", + "version": "1.99.3", + ]) : .hash([ + "name": "Xcode", + "version": .string(xcodeVersion() ?? "16.0"), + ]), + "editorPluginInfo": .hash([ + "name": "Copilot for Xcode", + "version": .string(Bundle.main + .infoDictionary?["CFBundleShortVersionString"] as? String ?? ""), + ]), +// "copilotCapabilities": [ +// /// The editor has support for watching files over LSP +// "watchedFiles": watchedFiles, +// ], + ], capabilities: capabilities, trace: .off, workspaceFolders: [WorkspaceFolder( - uri: projectRootURL.path, + uri: projectRootURL.absoluteString, name: projectRootURL.lastPathComponent )] ) @@ -438,7 +464,7 @@ public final class GitHubCopilotService: GitHubCopilotBaseService, do { let completions = try await server .sendRequest(GitHubCopilotRequest.InlineCompletion(doc: .init( - textDocument: .init(uri: fileURL.path, version: 1), + textDocument: .init(uri: fileURL.absoluteString, version: 1), position: cursorPosition, formattingOptions: .init( tabSize: tabSize, @@ -497,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() @@ -629,7 +667,7 @@ extension InitializingServer: GitHubCopilotLSP { } } -private func xcodeVersion() async -> String? { +private func xcodeVersion() -> String? { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") process.arguments = ["xcodebuild", "-version"] diff --git a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotChatService.swift b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotChatService.swift index 1534b961..280b7068 100644 --- a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotChatService.swift +++ b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotChatService.swift @@ -31,8 +31,8 @@ public final class GitHubCopilotChatService: BuiltinExtensionChatServiceType { tabSize: 1, indentSize: 4, insertSpaces: true, - path: editorContent?.documentURL.path ?? "", - uri: editorContent?.documentURL.path ?? "", + path: editorContent?.documentURL.absoluteString ?? "", + uri: editorContent?.documentURL.absoluteString ?? "", relativePath: editorContent?.relativePath ?? "", languageId: editorContent?.language ?? .plaintext, position: editorContent?.editorContent?.cursorPosition ?? .zero @@ -40,10 +40,17 @@ public final class GitHubCopilotChatService: BuiltinExtensionChatServiceType { let request = GitHubCopilotRequest.ConversationCreate(requestBody: .init( workDoneToken: workDoneToken, turns: turns, - capabilities: .init(allSkills: false, skills: []), - doc: doc, + capabilities: .init(skills: [], allSkills: false), + textDocument: doc, source: .panel, - workspaceFolder: workspace.projectURL.path, + workspaceFolder: workspace.projectURL.absoluteString, + model: { + let selectedModel = UserDefaults.shared.value(for: \.gitHubCopilotModelId) + if selectedModel.isEmpty { + return nil + } + return selectedModel + }(), userLanguage: { let language = UserDefaults.shared.value(for: \.chatGPTLanguage) if language.isEmpty { @@ -90,6 +97,12 @@ public final class GitHubCopilotChatService: BuiltinExtensionChatServiceType { "\(error). Please try enabling pretend IDE to be VSCode and click refresh configuration." ) ) + } else if error.contains("No model configuration found") { + continuation.finish( + throwing: GitHubCopilotError.chatEndsWithError( + "\(error). Please try enabling pretend IDE to be VSCode and click refresh configuration." + ) + ) } else { continuation.finish( throwing: GitHubCopilotError.chatEndsWithError(error) @@ -141,7 +154,7 @@ public final class GitHubCopilotChatService: BuiltinExtensionChatServiceType { } extension GitHubCopilotChatService { - typealias Turn = GitHubCopilotRequest.ConversationCreate.RequestBody.Turn + typealias Turn = GitHubCopilotRequest.ConversationCreate.RequestBody.ConversationTurn func convertHistory(history: [Message], message: String) -> [Turn] { guard let firstIndexOfUserMessage = history.firstIndex(where: { $0.role == .user }) else { return [.init(request: message, response: nil)] } @@ -221,6 +234,10 @@ extension GitHubCopilotChatService { var message: String? } + struct Annotation: Decodable { + var id: Int + } + var kind: String var title: String? var conversationId: String @@ -229,7 +246,7 @@ extension GitHubCopilotChatService { var followUp: FollowUp? var suggestedTitle: String? var reply: String? - var annotations: [String]? + var annotations: [Annotation]? var hideText: Bool? var cancellationReason: String? var error: Error? diff --git a/Tool/Sources/GitIgnoreCheck/GitIgnoreCheck.swift b/Tool/Sources/GitIgnoreCheck/GitIgnoreCheck.swift index b2c48114..53f5bf39 100644 --- a/Tool/Sources/GitIgnoreCheck/GitIgnoreCheck.swift +++ b/Tool/Sources/GitIgnoreCheck/GitIgnoreCheck.swift @@ -54,9 +54,9 @@ public struct DefaultGitIgnoredChecker: GitIgnoredChecker { do { let result = try await terminal.runCommand( "/bin/bash", - arguments: ["-c", "git check-ignore \"\(fileURL.path)\""], + arguments: ["-c", "git check-ignore ${TARGET_FILE}"], currentDirectoryURL: gitFolderURL, - environment: [:] + environment: ["TARGET_FILE": fileURL.path] ) if result.isEmpty { return false } return true @@ -76,9 +76,9 @@ public struct DefaultGitIgnoredChecker: GitIgnoredChecker { do { let result = try await terminal.runCommand( "/bin/bash", - arguments: ["-c", "git check-ignore \(filePaths)"], + arguments: ["-c", "git check-ignore ${TARGET_FILE}"], currentDirectoryURL: gitFolderURL, - environment: [:] + environment: ["TARGET_FILE": filePaths] ) return result .split(whereSeparator: \.isNewline) diff --git a/Tool/Sources/JoinJSON/JoinJSON.swift b/Tool/Sources/JoinJSON/JoinJSON.swift new file mode 100644 index 00000000..26181e88 --- /dev/null +++ b/Tool/Sources/JoinJSON/JoinJSON.swift @@ -0,0 +1,29 @@ +import Foundation + +public struct JoinJSON { + public init() {} + + public func join(_ a: String, with b: String) -> Data { + return join(a.data(using: .utf8) ?? Data(), with: b.data(using: .utf8) ?? Data()) + } + + public func join(_ a: Data, with b: String) -> Data { + return join(a, with: b.data(using: .utf8) ?? Data()) + } + + public func join(_ a: Data, with b: Data) -> Data { + guard let firstDict = try? JSONSerialization.jsonObject(with: a) as? [String: Any], + let secondDict = try? JSONSerialization.jsonObject(with: b) as? [String: Any] + else { + return a + } + + var merged = firstDict + for (key, value) in secondDict { + merged[key] = value + } + + return (try? JSONSerialization.data(withJSONObject: merged)) ?? a + } +} + diff --git a/Tool/Sources/LangChain/DocumentTransformer/TextSplitter.swift b/Tool/Sources/LangChain/DocumentTransformer/TextSplitter.swift index 4e467106..5880616c 100644 --- a/Tool/Sources/LangChain/DocumentTransformer/TextSplitter.swift +++ b/Tool/Sources/LangChain/DocumentTransformer/TextSplitter.swift @@ -26,6 +26,9 @@ public extension TextSplitter { for (text, metadata) in zip(texts, metadata) { let chunks = try await split(text: text) for chunk in chunks { + var metadata = metadata + metadata["startUTF16Offset"] = .number(Double(chunk.startUTF16Offset)) + metadata["endUTF16Offset"] = .number(Double(chunk.endUTF16Offset)) let document = Document(pageContent: chunk.text, metadata: metadata) documents.append(document) } @@ -48,6 +51,41 @@ public extension TextSplitter { func transformDocuments(_ documents: [Document]) async throws -> [Document] { return try await splitDocuments(documents) } + + func joinDocuments(_ documents: [Document]) -> Document { + let textChunks: [TextChunk] = documents.compactMap { document in + func extract(_ key: String) -> Int? { + if case let .number(d) = document.metadata[key] { + return Int(d) + } + return nil + } + guard let start = extract("startUTF16Offset"), + let end = extract("endUTF16Offset") + else { return nil } + return TextChunk( + text: document.pageContent, + startUTF16Offset: start, + endUTF16Offset: end + ) + }.sorted(by: { $0.startUTF16Offset < $1.startUTF16Offset }) + var sumChunk: TextChunk? + for chunk in textChunks { + if let current = sumChunk { + if let merged = current.merged(with: chunk, force: true) { + sumChunk = merged + } + } else { + sumChunk = chunk + } + } + let pageContent = sumChunk?.text ?? "" + var metadata = documents.first?.metadata ?? [String: JSONValue]() + metadata["startUTF16Offset"] = nil + metadata["endUTF16Offset"] = nil + + return Document(pageContent: pageContent, metadata: metadata) + } } public struct TextChunk: Equatable { @@ -83,14 +121,14 @@ public extension TextSplitter { let text = (a + b).map(\.text).joined() var l = Int.max var u = 0 - + for chunk in a + b { l = min(l, chunk.startUTF16Offset) u = max(u, chunk.endUTF16Offset) } - + guard l < u else { return nil } - + return .init(text: text, startUTF16Offset: l, endUTF16Offset: u) } diff --git a/Tool/Sources/Logger/Logger.swift b/Tool/Sources/Logger/Logger.swift index 390e54d5..58d280f0 100644 --- a/Tool/Sources/Logger/Logger.swift +++ b/Tool/Sources/Logger/Logger.swift @@ -23,6 +23,7 @@ public final class Logger { public static let license = Logger(category: "License") public static let `extension` = Logger(category: "Extension") public static let communicationBridge = Logger(category: "CommunicationBridge") + public static let chatProxy = Logger(category: "ChatProxy") public static let debug = Logger(category: "Debug") #if DEBUG /// Use a temp logger to log something temporary. I won't be available in release builds. @@ -52,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 3d6c8193..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 { @@ -101,13 +102,16 @@ public enum ModificationAttachedTarget: Equatable { public struct ModificationHistoryNode { public var snippets: IdentifiedArrayOf public var instruction: NSAttributedString + public var references: [ChatMessage.Reference] public init( snippets: IdentifiedArrayOf, - instruction: NSAttributedString + instruction: NSAttributedString, + references: [ChatMessage.Reference] ) { self.snippets = snippets self.instruction = instruction + self.references = references } } diff --git a/Tool/Sources/ModificationBasic/ModificationState.swift b/Tool/Sources/ModificationBasic/ModificationState.swift index 40bbe8f0..51b7b28b 100644 --- a/Tool/Sources/ModificationBasic/ModificationState.swift +++ b/Tool/Sources/ModificationBasic/ModificationState.swift @@ -1,3 +1,4 @@ +import ChatBasic import Foundation import IdentifiedCollections import SuggestionBasic @@ -12,6 +13,7 @@ public struct ModificationState { public var extraSystemPrompt: String public var isAttachedToTarget: Bool = true public var status = [String]() + public var references: [ChatMessage.Reference] = [] public init( source: Source, @@ -20,7 +22,8 @@ public struct ModificationState { extraSystemPrompt: String, isAttachedToTarget: Bool, isGenerating: Bool = false, - status: [String] = [] + status: [String] = [], + references: [ChatMessage.Reference] = [] ) { self.history = history self.snippets = snippets @@ -29,6 +32,7 @@ public struct ModificationState { self.extraSystemPrompt = extraSystemPrompt self.source = source self.status = status + self.references = references } public init( @@ -58,16 +62,17 @@ public struct ModificationState { public mutating func popHistory() -> NSAttributedString? { if !history.isEmpty { let last = history.removeLast() + references = last.references snippets = last.snippets let instruction = last.instruction return instruction } - + return nil } public mutating func pushHistory(instruction: NSAttributedString) { - history.append(.init(snippets: snippets, instruction: instruction)) + history.append(.init(snippets: snippets, instruction: instruction, references: references)) let oldSnippets = snippets snippets = IdentifiedArrayOf() for var snippet in oldSnippets { diff --git a/Tool/Sources/OpenAIService/APIs/BuiltinExtensionChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/BuiltinExtensionChatCompletionsService.swift index c08435ae..888c301f 100644 --- a/Tool/Sources/OpenAIService/APIs/BuiltinExtensionChatCompletionsService.swift +++ b/Tool/Sources/OpenAIService/APIs/BuiltinExtensionChatCompletionsService.swift @@ -62,8 +62,8 @@ extension BuiltinExtensionChatCompletionsService: ChatCompletionsStreamAPI { ) async throws -> AsyncThrowingStream { let service = try getChatService() let (message, history) = extractMessageAndHistory(from: requestBody) - guard let workspaceURL = await XcodeInspector.shared.safe.realtimeActiveWorkspaceURL, - let projectURL = await XcodeInspector.shared.safe.realtimeActiveProjectURL + guard let workspaceURL = XcodeInspector.shared.realtimeActiveWorkspaceURL, + let projectURL = XcodeInspector.shared.realtimeActiveProjectURL else { throw CancellationError() } let stream = await service.sendMessage( message, diff --git a/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIBuilder.swift b/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIBuilder.swift index f114b32d..8f8e3feb 100644 --- a/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIBuilder.swift +++ b/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIBuilder.swift @@ -62,6 +62,11 @@ struct DefaultChatCompletionsAPIBuilder: ChatCompletionsAPIBuilder { endpoint: endpoint, requestBody: requestBody ) + case .gitHubCopilot: + return GitHubCopilotChatCompletionsService( + model: model, + requestBody: requestBody + ) } } @@ -107,6 +112,11 @@ struct DefaultChatCompletionsAPIBuilder: ChatCompletionsAPIBuilder { endpoint: endpoint, requestBody: requestBody ) + case .gitHubCopilot: + return GitHubCopilotChatCompletionsService( + model: model, + requestBody: requestBody + ) } } } @@ -121,3 +131,4 @@ extension DependencyValues { set { self[ChatCompletionsAPIBuilderDependencyKey.self] = newValue } } } + diff --git a/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIDefinition.swift b/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIDefinition.swift index f8f1a5ff..76325f6f 100644 --- a/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIDefinition.swift +++ b/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIDefinition.swift @@ -32,12 +32,11 @@ struct ChatCompletionsRequestBody: Equatable { case jpeg = "image/jpeg" case gif = "image/gif" } - var data: Data + var base64EncodeData: String var format: Format var dataURLString: String { - let base64 = data.base64EncodedString() - return "data:\(format.rawValue);base64,\(base64)" + return "data:\(format.rawValue);base64,\(base64EncodeData)" } } @@ -172,6 +171,23 @@ protocol ChatCompletionsStreamAPI { func callAsFunction() async throws -> AsyncThrowingStream } +extension ChatCompletionsStreamAPI { + 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) + } + } +} + extension AsyncSequence { func toStream() -> AsyncThrowingStream { AsyncThrowingStream { continuation in @@ -209,6 +225,7 @@ struct ChatCompletionsStreamDataChunk { var role: ChatCompletionsRequestBody.Message.Role? var content: String? + var reasoningContent: String? var toolCalls: [ToolCall]? } @@ -243,6 +260,8 @@ struct ChatCompletionResponseBody: Equatable { var role: Role /// The content of the message. var content: String? + /// The reasoning content of the message. + var reasoningContent: String? /// When we want to reply to a function call with the result, we have to provide the /// name of the function call, and include the result in `content`. /// diff --git a/Tool/Sources/OpenAIService/APIs/ClaudeChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/ClaudeChatCompletionsService.swift index c57935b0..223eab79 100644 --- a/Tool/Sources/OpenAIService/APIs/ClaudeChatCompletionsService.swift +++ b/Tool/Sources/OpenAIService/APIs/ClaudeChatCompletionsService.swift @@ -3,13 +3,16 @@ import AsyncAlgorithms import ChatBasic import CodableWrappers import Foundation +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 public enum KnownModel: String, CaseIterable { + case claude37Sonnet = "claude-3-7-sonnet-latest" case claude35Sonnet = "claude-3-5-sonnet-latest" case claude35Haiku = "claude-3-5-haiku-latest" case claude3Opus = "claude-3-opus-latest" @@ -23,6 +26,7 @@ public actor ClaudeChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet case .claude3Opus: return 200_000 case .claude3Sonnet: return 200_000 case .claude3Haiku: return 200_000 + case .claude37Sonnet: return 200_000 } } } @@ -41,7 +45,7 @@ public actor ClaudeChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet } } - enum MessageRole: String, Codable { + public enum MessageRole: String, Codable { case user case assistant @@ -124,42 +128,42 @@ public actor ClaudeChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet var stop_sequence: String? } - struct RequestBody: Encodable, Equatable { - struct CacheControl: Encodable, Equatable { - enum CacheControlType: String, Codable, Equatable { + public struct RequestBody: Codable, Equatable { + public struct CacheControl: Codable, Equatable, Sendable { + public enum CacheControlType: String, Codable, Equatable, Sendable { case ephemeral } - var type: CacheControlType = .ephemeral + 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 { @@ -179,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 @@ -232,6 +236,8 @@ public actor ClaudeChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet if !apiKey.isEmpty { request.setValue(apiKey, forHTTPHeaderField: "x-api-key") } + Self.setupCustomBody(&request, model: model) + await Self.setupExtraHeaderFields(&request, model: model, apiKey: apiKey) let (result, response) = try await URLSession.shared.bytes(for: request) guard let response = response as? HTTPURLResponse else { @@ -295,6 +301,8 @@ public actor ClaudeChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet if !apiKey.isEmpty { request.setValue(apiKey, forHTTPHeaderField: "x-api-key") } + Self.setupCustomBody(&request, model: model) + await Self.setupExtraHeaderFields(&request, model: model, apiKey: apiKey) let (result, response) = try await URLSession.shared.data(for: request) guard let response = response as? HTTPURLResponse else { @@ -315,6 +323,15 @@ public actor ClaudeChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet throw error } } + + static func setupCustomBody(_ request: inout URLRequest, model: ChatModel) { + let join = JoinJSON() + let jsonBody = model.info.customBodyInfo.jsonBody + .trimmingCharacters(in: .whitespacesAndNewlines) + guard let data = request.httpBody, !jsonBody.isEmpty else { return } + let newBody = join.join(data, with: jsonBody) + request.httpBody = newBody + } } extension ClaudeChatCompletionsService.ResponseBody { @@ -450,10 +467,10 @@ extension ClaudeChatCompletionsService.RequestBody { content.append(.init(type: .image, source: .init( type: "base64", media_type: image.format.rawValue, - data: image.data.base64EncodedString() + data: image.base64EncodeData ))) } - + return content } @@ -505,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/EmbeddingAPIDefinitions.swift b/Tool/Sources/OpenAIService/APIs/EmbeddingAPIDefinitions.swift index 2d099a0b..6a63ee7b 100644 --- a/Tool/Sources/OpenAIService/APIs/EmbeddingAPIDefinitions.swift +++ b/Tool/Sources/OpenAIService/APIs/EmbeddingAPIDefinitions.swift @@ -9,6 +9,23 @@ protocol EmbeddingAPI { func embed(tokens: [[Int]]) async throws -> EmbeddingResponse } +extension EmbeddingAPI { + static func setupExtraHeaderFields( + _ request: inout URLRequest, + model: EmbeddingModel, + 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) + } + } +} + public struct EmbeddingResponse: Decodable { public struct Object: Decodable { public var embedding: [Float] diff --git a/Tool/Sources/OpenAIService/APIs/GitHubCopilotChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/GitHubCopilotChatCompletionsService.swift new file mode 100644 index 00000000..2f200909 --- /dev/null +++ b/Tool/Sources/OpenAIService/APIs/GitHubCopilotChatCompletionsService.swift @@ -0,0 +1,112 @@ +import AIModel +import AsyncAlgorithms +import ChatBasic +import Foundation +import GitHubCopilotService +import Logger +import Preferences + +public enum AvailableGitHubCopilotModel: String, CaseIterable { + case claude35sonnet = "claude-3.5-sonnet" + case o1Mini = "o1-mini" + case o1 = "o1" + case gpt4Turbo = "gpt-4-turbo" + case gpt4oMini = "gpt-4o-mini" + case gpt4o = "gpt-4o" + case gpt4 = "gpt-4" + case gpt35Turbo = "gpt-3.5-turbo" + + public var contextWindow: Int { + switch self { + case .claude35sonnet: + return 200_000 + case .o1Mini: + return 128_000 + case .o1: + return 128_000 + case .gpt4Turbo: + return 128_000 + case .gpt4oMini: + return 128_000 + case .gpt4o: + return 128_000 + case .gpt4: + return 32_768 + case .gpt35Turbo: + return 16_384 + } + } +} + +/// Looks like it's used in many other popular repositories so maybe it's safe. +actor GitHubCopilotChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI { + + let chatModel: ChatModel + let requestBody: ChatCompletionsRequestBody + + init( + model: ChatModel, + requestBody: ChatCompletionsRequestBody + ) { + var model = model + model.format = .openAICompatible + chatModel = model + self.requestBody = requestBody + } + + func callAsFunction() async throws + -> AsyncThrowingStream + { + let service = try await buildService() + return try await service() + } + + func callAsFunction() async throws -> ChatCompletionResponseBody { + let service = try await buildService() + return try await service() + } + + private func buildService() async throws -> OpenAIChatCompletionsService { + let token = try await GitHubCopilotExtension.fetchToken() + + guard let endpoint = URL(string: token.endpoints.api + "/chat/completions") else { + throw ChatGPTServiceError.endpointIncorrect + } + + return OpenAIChatCompletionsService( + apiKey: token.token, + model: chatModel, + endpoint: endpoint, + requestBody: requestBody + ) { request in + +// POST /chat/completions HTTP/2 +// :authority: api.individual.githubcopilot.com +// authorization: Bearer * +// x-request-id: * +// openai-organization: github-copilot +// vscode-sessionid: * +// vscode-machineid: * +// editor-version: vscode/1.89.1 +// editor-plugin-version: Copilot for Xcode/0.35.5 +// copilot-language-server-version: 1.236.0 +// x-github-api-version: 2023-07-07 +// openai-intent: conversation-panel +// content-type: application/json +// user-agent: GithubCopilot/1.236.0 +// content-length: 9061 +// accept: */* +// accept-encoding: gzip,deflate,br + + 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") + } + } +} + diff --git a/Tool/Sources/OpenAIService/APIs/GitHubCopilotEmbeddingService.swift b/Tool/Sources/OpenAIService/APIs/GitHubCopilotEmbeddingService.swift new file mode 100644 index 00000000..627694a4 --- /dev/null +++ b/Tool/Sources/OpenAIService/APIs/GitHubCopilotEmbeddingService.swift @@ -0,0 +1,72 @@ +import AIModel +import AsyncAlgorithms +import ChatBasic +import Foundation +import GitHubCopilotService +import Logger +import Preferences + +/// Looks like it's used in many other popular repositories so maybe it's safe. +actor GitHubCopilotEmbeddingService: EmbeddingAPI { + let chatModel: EmbeddingModel + + init(model: EmbeddingModel) { + var model = model + model.format = .openAICompatible + chatModel = model + } + + func embed(text: String) async throws -> EmbeddingResponse { + let service = try await buildService() + return try await service.embed(text: text) + } + + func embed(texts: [String]) async throws -> EmbeddingResponse { + let service = try await buildService() + return try await service.embed(texts: texts) + } + + func embed(tokens: [[Int]]) async throws -> EmbeddingResponse { + let service = try await buildService() + return try await service.embed(tokens: tokens) + } + + private func buildService() async throws -> OpenAIEmbeddingService { + let token = try await GitHubCopilotExtension.fetchToken() + + return OpenAIEmbeddingService( + apiKey: token.token, + model: chatModel, + endpoint: token.endpoints.api + "/embeddings" + ) { request in + +// POST /chat/completions HTTP/2 +// :authority: api.individual.githubcopilot.com +// authorization: Bearer * +// x-request-id: * +// openai-organization: github-copilot +// vscode-sessionid: * +// vscode-machineid: * +// editor-version: vscode/1.89.1 +// editor-plugin-version: Copilot for Xcode/0.35.5 +// copilot-language-server-version: 1.236.0 +// x-github-api-version: 2023-07-07 +// openai-intent: conversation-panel +// content-type: application/json +// user-agent: GithubCopilot/1.236.0 +// content-length: 9061 +// accept: */* +// accept-encoding: gzip,deflate,br + + 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") + } + } +} + diff --git a/Tool/Sources/OpenAIService/APIs/OlamaChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/OlamaChatCompletionsService.swift index f95b2c74..9ac6e0dd 100644 --- a/Tool/Sources/OpenAIService/APIs/OlamaChatCompletionsService.swift +++ b/Tool/Sources/OpenAIService/APIs/OlamaChatCompletionsService.swift @@ -59,6 +59,13 @@ extension OllamaChatCompletionsService: ChatCompletionsAPI { let encoder = JSONEncoder() request.httpBody = try encoder.encode(requestBody) request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + if !apiKey.isEmpty { + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + } + + await Self.setupExtraHeaderFields(&request, model: model, apiKey: apiKey) + let (result, response) = try await URLSession.shared.data(for: request) guard let response = response as? HTTPURLResponse else { @@ -135,6 +142,13 @@ extension OllamaChatCompletionsService: ChatCompletionsStreamAPI { let encoder = JSONEncoder() request.httpBody = try encoder.encode(requestBody) request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + if !apiKey.isEmpty { + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + } + + await Self.setupExtraHeaderFields(&request, model: model, apiKey: apiKey) + let (result, response) = try await URLSession.shared.bytes(for: request) guard let response = response as? HTTPURLResponse else { diff --git a/Tool/Sources/OpenAIService/APIs/OllamaEmbeddingService.swift b/Tool/Sources/OpenAIService/APIs/OllamaEmbeddingService.swift index dfd170cc..1e0f2933 100644 --- a/Tool/Sources/OpenAIService/APIs/OllamaEmbeddingService.swift +++ b/Tool/Sources/OpenAIService/APIs/OllamaEmbeddingService.swift @@ -12,6 +12,7 @@ struct OllamaEmbeddingService: EmbeddingAPI { var embedding: [Float] } + let apiKey: String let model: EmbeddingModel let endpoint: String @@ -25,6 +26,14 @@ struct OllamaEmbeddingService: EmbeddingAPI { model: model.info.modelName )) request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + if !apiKey.isEmpty { + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + } + + for field in model.info.customHeaderInfo.headers where !field.key.isEmpty { + request.setValue(field.value, forHTTPHeaderField: field.key) + } let (result, response) = try await URLSession.shared.data(for: request) guard let response = response as? HTTPURLResponse else { diff --git a/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift index 1ad0c4d9..93c5987d 100644 --- a/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift +++ b/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift @@ -2,11 +2,12 @@ import AIModel import AsyncAlgorithms import ChatBasic import Foundation +import JoinJSON import Logger import Preferences /// https://platform.openai.com/docs/api-reference/chat/create -actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI { +public actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI { struct CompletionAPIError: Error, Decodable, LocalizedError { struct ErrorDetail: Decodable { var message: String @@ -67,16 +68,18 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI } } - enum MessageRole: String, Codable { + public enum MessageRole: String, Codable, Sendable { case system case user 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 @@ -85,38 +88,84 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI } } - struct StreamDataChunk: Codable { - var id: String? - var object: String? - var model: String? - var choices: [Choice]? - var usage: ResponseBody.Usage? - - struct Choice: Codable { - var delta: Delta? - var index: Int? - var finish_reason: String? + public struct StreamDataChunk: Codable, Sendable { + public var id: String? + public var provider: String? + public var object: String? + public var model: String? + public var choices: [Choice]? + public var usage: ResponseBody.Usage? + public var created: Int? + + public struct Choice: Codable, Sendable { + public var delta: Delta? + public var index: Int? + public var finish_reason: String? + + public struct Delta: Codable, Sendable { + public var role: MessageRole? + public var content: String? + public var reasoning_content: String? + public var reasoning: String? + public var function_call: RequestBody.MessageFunctionCall? + public var tool_calls: [RequestBody.MessageToolCall]? + + public init( + role: MessageRole? = nil, + content: String? = nil, + reasoning_content: String? = nil, + reasoning: String? = nil, + function_call: RequestBody.MessageFunctionCall? = nil, + tool_calls: [RequestBody.MessageToolCall]? = nil + ) { + self.role = role + self.content = content + self.reasoning_content = reasoning_content + self.reasoning = reasoning + self.function_call = function_call + self.tool_calls = tool_calls + } + } - struct Delta: Codable { - var role: MessageRole? - var content: String? - var function_call: RequestBody.MessageFunctionCall? - var tool_calls: [RequestBody.MessageToolCall]? + public init(delta: Delta? = nil, index: Int? = nil, finish_reason: String? = nil) { + self.delta = delta + self.index = index + self.finish_reason = finish_reason } } + + public init( + id: String? = nil, + provider: String? = nil, + object: String? = nil, + model: String? = nil, + choices: [Choice]? = nil, + usage: ResponseBody.Usage? = nil, + created: Int? = nil + ) { + self.id = id + self.provider = provider + self.object = object + self.model = model + self.choices = choices + self.usage = usage + self.created = created + } } - struct ResponseBody: Codable, Equatable { - struct Message: Codable, Equatable { + public struct ResponseBody: Codable, Equatable { + public struct Message: Codable, Equatable, Sendable { /// The role of the message. - var role: MessageRole + public var role: MessageRole /// The content of the message. - var content: String? + public var content: String? + public var reasoning_content: String? + public var reasoning: String? /// When we want to reply to a function call with the result, we have to provide the /// name of the function call, and include the result in `content`. /// /// - important: It's required when the role is `function`. - var name: String? + public var name: String? /// When the bot wants to call a function, it will reply with a function call in format: /// ```json /// { @@ -124,79 +173,179 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI /// "arguments": "{ \"location\": \"earth\" }" /// } /// ``` - var function_call: RequestBody.MessageFunctionCall? + public var function_call: RequestBody.MessageFunctionCall? /// Tool calls in an assistant message. - var tool_calls: [RequestBody.MessageToolCall]? + public var tool_calls: [RequestBody.MessageToolCall]? + + public init( + role: MessageRole, + content: String? = nil, + reasoning_content: String? = nil, + reasoning: String? = nil, + name: String? = nil, + function_call: RequestBody.MessageFunctionCall? = nil, + tool_calls: [RequestBody.MessageToolCall]? = nil + ) { + self.role = role + self.content = content + self.reasoning_content = reasoning_content + self.reasoning = reasoning + self.name = name + self.function_call = function_call + self.tool_calls = tool_calls + } } - struct Choice: Codable, Equatable { - var message: Message - var index: Int? - var finish_reason: String? + public struct Choice: Codable, Equatable, Sendable { + public var message: Message + public var index: Int? + public var finish_reason: String? + + public init(message: Message, index: Int? = nil, finish_reason: String? = nil) { + self.message = message + self.index = index + self.finish_reason = finish_reason + } } - struct Usage: Codable, Equatable { - var prompt_tokens: Int? - var completion_tokens: Int? - var total_tokens: Int? - var prompt_tokens_details: PromptTokensDetails? - var completion_tokens_details: CompletionTokensDetails? + public struct Usage: Codable, Equatable, Sendable { + public var prompt_tokens: Int? + public var completion_tokens: Int? + public var total_tokens: Int? + public var prompt_tokens_details: PromptTokensDetails? + public var completion_tokens_details: CompletionTokensDetails? + + public struct PromptTokensDetails: Codable, Equatable, Sendable { + public var cached_tokens: Int? + public var audio_tokens: Int? + + public init(cached_tokens: Int? = nil, audio_tokens: Int? = nil) { + self.cached_tokens = cached_tokens + self.audio_tokens = audio_tokens + } + } + + public struct CompletionTokensDetails: Codable, Equatable, Sendable { + public var reasoning_tokens: Int? + public var audio_tokens: Int? - struct PromptTokensDetails: Codable, Equatable { - var cached_tokens: Int? - var audio_tokens: Int? + public init(reasoning_tokens: Int? = nil, audio_tokens: Int? = nil) { + self.reasoning_tokens = reasoning_tokens + self.audio_tokens = audio_tokens + } } - struct CompletionTokensDetails: Codable, Equatable { - var reasoning_tokens: Int? - var audio_tokens: Int? + public init( + prompt_tokens: Int? = nil, + completion_tokens: Int? = nil, + total_tokens: Int? = nil, + prompt_tokens_details: PromptTokensDetails? = nil, + completion_tokens_details: CompletionTokensDetails? = nil + ) { + self.prompt_tokens = prompt_tokens + self.completion_tokens = completion_tokens + self.total_tokens = total_tokens + self.prompt_tokens_details = prompt_tokens_details + self.completion_tokens_details = completion_tokens_details } } - var id: String? - var object: String - var model: String - var usage: Usage - var choices: [Choice] + public var id: String? + public var object: String + public var model: String + public var usage: Usage + public var choices: [Choice] + + public init( + id: String? = nil, + object: String, + model: String, + usage: Usage, + choices: [Choice] + ) { + self.id = id + self.object = object + self.model = model + self.usage = usage + self.choices = choices + } } - struct RequestBody: Encodable, Equatable { - typealias ClaudeCacheControl = ClaudeChatCompletionsService.RequestBody.CacheControl + public struct RequestBody: Codable, Equatable { + public typealias ClaudeCacheControl = ClaudeChatCompletionsService.RequestBody.CacheControl + + public struct GitHubCopilotCacheControl: Codable, Equatable, Sendable { + public var type: String - struct Message: Encodable, Equatable { - enum MessageContent: Encodable, Equatable { - struct TextContentPart: Encodable, Equatable { - var type = "text" - var text: String - var cache_control: ClaudeCacheControl? + public init(type: String = "ephemeral") { + self.type = type + } + } + + public struct Message: Codable, Equatable { + public enum MessageContent: Codable, Equatable { + public struct TextContentPart: Codable, Equatable { + public var type = "text" + public var text: String + public var cache_control: ClaudeCacheControl? + + public init( + type: String = "text", + text: String, + cache_control: ClaudeCacheControl? = nil + ) { + self.type = type + self.text = text + self.cache_control = cache_control + } } - struct ImageContentPart: Encodable, Equatable { - struct ImageURL: Encodable, Equatable { - var url: String - var detail: String? + public struct ImageContentPart: Codable, Equatable { + public struct ImageURL: Codable, Equatable { + public var url: String + public var detail: String? + + public init(url: String, detail: String? = nil) { + self.url = url + self.detail = detail + } } - var type = "image_url" - var image_url: ImageURL + public var type = "image_url" + public var image_url: ImageURL + + public init(type: String = "image_url", image_url: ImageURL) { + self.type = type + self.image_url = image_url + } } - struct AudioContentPart: Encodable, Equatable { - struct InputAudio: Encodable, Equatable { - var data: String - var format: String + public struct AudioContentPart: Codable, Equatable { + public struct InputAudio: Codable, Equatable { + public var data: String + public var format: String + + public init(data: String, format: String) { + self.data = data + self.format = format + } } - var type = "input_audio" - var input_audio: InputAudio + public var type = "input_audio" + public var input_audio: InputAudio + + public init(type: String = "input_audio", input_audio: InputAudio) { + self.type = type + self.input_audio = input_audio + } } - enum ContentPart: Encodable, Equatable { + public enum ContentPart: Codable, Equatable { case text(TextContentPart) case image(ImageContentPart) case audio(AudioContentPart) - func encode(to encoder: any Encoder) throws { + public func encode(to encoder: any Encoder) throws { var container = encoder.singleValueContainer() switch self { case let .text(text): @@ -207,12 +356,58 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI try container.encode(audio) } } + + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + var errors: [Error] = [] + + do { + let text = try container.decode(String.self) + self = .text(.init(text: text)) + return + } catch { + errors.append(error) + } + + do { + let text = try container.decode(TextContentPart.self) + self = .text(text) + return + } catch { + errors.append(error) + } + + do { + let image = try container.decode(ImageContentPart.self) + self = .image(image) + return + } catch { + errors.append(error) + } + + do { + let audio = try container.decode(AudioContentPart.self) + self = .audio(audio) + return + } catch { + errors.append(error) + } + + struct E: Error, LocalizedError { + let errors: [Error] + + var errorDescription: String? { + "Failed to decode ContentPart: \(errors.map { $0.localizedDescription }.joined(separator: "; "))" + } + } + throw E(errors: errors) + } } case contentParts([ContentPart]) case text(String) - func encode(to encoder: any Encoder) throws { + public func encode(to encoder: any Encoder) throws { var container = encoder.singleValueContainer() switch self { case let .contentParts(parts): @@ -221,77 +416,186 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI try container.encode(text) } } + + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + var errors: [Error] = [] + + do { + let parts = try container.decode([ContentPart].self) + self = .contentParts(parts) + return + } catch { + errors.append(error) + } + + do { + let text = try container.decode(String.self) + self = .text(text) + return + } catch { + errors.append(error) + } + + do { // Null + _ = try container.decode([ContentPart]?.self) + self = .contentParts([]) + return + } catch { + errors.append(error) + } + + struct E: Error, LocalizedError { + let errors: [Error] + + var errorDescription: String? { + "Failed to decode MessageContent: \(errors.map { $0.localizedDescription }.joined(separator: "; "))" + } + } + throw E(errors: errors) + } } /// The role of the message. - var role: MessageRole + public var role: MessageRole /// The content of the message. - var content: MessageContent + public var content: MessageContent /// When we want to reply to a function call with the result, we have to provide the /// name of the function call, and include the result in `content`. /// /// - important: It's required when the role is `function`. - var name: String? + public var name: String? /// Tool calls in an assistant message. - var tool_calls: [MessageToolCall]? + public var tool_calls: [MessageToolCall]? /// When we want to call a tool, we have to provide the id of the call. /// /// - important: It's required when the role is `tool`. - var tool_call_id: String? + public var tool_call_id: String? /// When the bot wants to call a function, it will reply with a function call. /// /// Deprecated. - var function_call: MessageFunctionCall? + 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, + content: MessageContent, + name: String? = nil, + tool_calls: [MessageToolCall]? = nil, + tool_call_id: String? = nil, + function_call: MessageFunctionCall? = nil, + copilot_cache_control: GitHubCopilotCacheControl? = nil + ) { + self.role = role + self.content = content + self.name = name + self.tool_calls = tool_calls + self.tool_call_id = tool_call_id + self.function_call = function_call + self.copilot_cache_control = copilot_cache_control + } } - struct MessageFunctionCall: Codable, Equatable { + public struct MessageFunctionCall: Codable, Equatable, Sendable { /// The name of the - var name: String? + public var name: String? /// A JSON string. - var arguments: String? + public var arguments: String? + + public init(name: String? = nil, arguments: String? = nil) { + self.name = name + self.arguments = arguments + } } - struct MessageToolCall: Codable, Equatable { + public struct MessageToolCall: Codable, Equatable, Sendable { /// When it's returned as a data chunk, use the index to identify the tool call. - var index: Int? + public var index: Int? /// The id of the tool call. - var id: String? + public var id: String? /// The type of the tool. - var type: String? + public var type: String? /// The function call. - var function: MessageFunctionCall? + public var function: MessageFunctionCall? + + public init( + index: Int? = nil, + id: String? = nil, + type: String? = nil, + function: MessageFunctionCall? = nil + ) { + self.index = index + self.id = id + self.type = type + self.function = function + } } - struct Tool: Encodable, Equatable { - var type: String = "function" - var function: ChatGPTFunctionSchema + public struct Tool: Codable, Equatable, Sendable { + public var type: String = "function" + public var function: ChatGPTFunctionSchema + + public init(type: String, function: ChatGPTFunctionSchema) { + self.type = type + self.function = function + } } - struct StreamOptions: Encodable, Equatable { - var include_usage: Bool = true + public struct StreamOptions: Codable, Equatable, Sendable { + public var include_usage: Bool = true + + public init(include_usage: Bool = true) { + self.include_usage = include_usage + } } - var model: String - var messages: [Message] - var temperature: Double? - var stream: Bool? - var stop: [String]? - var max_completion_tokens: Int? - var tool_choice: FunctionCallStrategy? - var tools: [Tool]? - var stream_options: StreamOptions? + public var model: String + public var messages: [Message] + public var temperature: Double? + public var stream: Bool? + public var stop: [String]? + public var max_completion_tokens: Int? + public var tool_choice: FunctionCallStrategy? + public var tools: [Tool]? + public var stream_options: StreamOptions? + + public init( + model: String, + messages: [Message], + temperature: Double? = nil, + stream: Bool? = nil, + stop: [String]? = nil, + max_completion_tokens: Int? = nil, + tool_choice: FunctionCallStrategy? = nil, + tools: [Tool]? = nil, + stream_options: StreamOptions? = nil + ) { + self.model = model + self.messages = messages + self.temperature = temperature + self.stream = stream + self.stop = stop + self.max_completion_tokens = max_completion_tokens + self.tool_choice = tool_choice + self.tools = tools + self.stream_options = stream_options + } } var apiKey: String var endpoint: URL var requestBody: RequestBody var model: ChatModel + let requestModifier: ((inout URLRequest) -> Void)? init( apiKey: String, model: ChatModel, endpoint: URL, - requestBody: ChatCompletionsRequestBody + requestBody: ChatCompletionsRequestBody, + requestModifier: ((inout URLRequest) -> Void)? = nil ) { self.apiKey = apiKey self.endpoint = endpoint @@ -301,11 +605,32 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI enforceMessageOrder: model.info.openAICompatibleInfo.enforceMessageOrder, supportsMultipartMessageContent: model.info.openAICompatibleInfo .supportsMultipartMessageContent, + requiresBeginWithUserMessage: model.info.openAICompatibleInfo + .requiresBeginWithUserMessage, canUseTool: model.info.supportsFunctionCalling, supportsImage: model.info.supportsImage, - supportsAudio: model.info.supportsAudio + supportsAudio: model.info.supportsAudio, + supportsTemperature: { + guard model.format == .openAI else { return true } + if let chatGPTModel = ChatGPTModel(rawValue: model.info.modelName) { + return chatGPTModel.supportsTemperature + } else if model.info.modelName.hasPrefix("o") { + return false + } + return true + }(), + supportsSystemPrompt: { + guard model.format == .openAI else { return true } + if let chatGPTModel = ChatGPTModel(rawValue: model.info.modelName) { + return chatGPTModel.supportsSystemPrompt + } else if model.info.modelName.hasPrefix("o") { + return false + } + return true + }() ) self.model = model + self.requestModifier = requestModifier } func callAsFunction() async throws @@ -318,9 +643,12 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI request.httpBody = try encoder.encode(requestBody) request.setValue("application/json", forHTTPHeaderField: "Content-Type") + Self.setupCustomBody(&request, model: model) Self.setupAppInformation(&request) Self.setupAPIKey(&request, model: model, apiKey: apiKey) - Self.setupExtraHeaderFields(&request, model: model) + 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 { @@ -456,6 +784,8 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") case .azureOpenAI: request.setValue(apiKey, forHTTPHeaderField: "api-key") + case .gitHubCopilot: + break case .googleAI: assertionFailure("Unsupported") case .ollama: @@ -466,11 +796,27 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI } } - static func setupExtraHeaderFields(_ request: inout URLRequest, model: ChatModel) { - for field in model.info.customHeaderInfo.headers where !field.key.isEmpty { - request.setValue(field.value, forHTTPHeaderField: field.key) + 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: + break + default: + return + } + + let join = JoinJSON() + let jsonBody = model.info.customBodyInfo.jsonBody + .trimmingCharacters(in: .whitespacesAndNewlines) + guard let data = request.httpBody, !jsonBody.isEmpty else { return } + let newBody = join.join(data, with: jsonBody) + request.httpBody = newBody + } } extension OpenAIChatCompletionsService.ResponseBody { @@ -482,6 +828,7 @@ extension OpenAIChatCompletionsService.ResponseBody { .init( role: message.role.formalized, content: message.content ?? "", + reasoningContent: message.reasoning_content ?? message.reasoning ?? "", toolCalls: { if let toolCalls = message.tool_calls { return toolCalls.map { toolCall in @@ -553,6 +900,8 @@ extension OpenAIChatCompletionsService.StreamDataChunk { return .init( role: choice.delta?.role?.formalized, content: choice.delta?.content, + reasoningContent: choice.delta?.reasoning_content + ?? choice.delta?.reasoning, toolCalls: { if let toolCalls = choice.delta?.tool_calls { return toolCalls.map { @@ -661,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, @@ -681,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 { @@ -702,10 +1051,18 @@ extension OpenAIChatCompletionsService.RequestBody { endpoint: URL, enforceMessageOrder: Bool, supportsMultipartMessageContent: Bool, + requiresBeginWithUserMessage: Bool, canUseTool: Bool, supportsImage: Bool, - supportsAudio: Bool + supportsAudio: Bool, + supportsTemperature: Bool, + supportsSystemPrompt: Bool ) { + let supportsMultipartMessageContent = if supportsAudio || supportsImage { + true + } else { + supportsMultipartMessageContent + } temperature = body.temperature stream = body.stream stop = body.stop @@ -725,10 +1082,32 @@ extension OpenAIChatCompletionsService.RequestBody { model = body.model + var body = body + + if !supportsTemperature { + temperature = nil + } + if !supportsSystemPrompt { + for (index, message) in body.messages.enumerated() { + if message.role == .system { + body.messages[index].role = .user + } + } + } + + if requiresBeginWithUserMessage { + let firstUserIndex = body.messages.firstIndex(where: { $0.role == .user }) ?? 0 + let endIndex = firstUserIndex + for i in stride(from: endIndex - 1, to: 0, by: -1) + where i >= 0 && body.messages.endIndex > i + { + body.messages.remove(at: i) + } + } + // Special case for Claude through OpenRouter if endpoint.absoluteString.contains("openrouter.ai"), model.hasPrefix("anthropic/") { - var body = body body.model = model.replacingOccurrences(of: "anthropic/", with: "") let claudeRequestBody = ClaudeChatCompletionsService.RequestBody(body) messages = claudeRequestBody.system.map { @@ -893,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/OpenAIEmbeddingService.swift b/Tool/Sources/OpenAIService/APIs/OpenAIEmbeddingService.swift index d6fe2780..f6edf3b7 100644 --- a/Tool/Sources/OpenAIService/APIs/OpenAIEmbeddingService.swift +++ b/Tool/Sources/OpenAIService/APIs/OpenAIEmbeddingService.swift @@ -16,6 +16,7 @@ struct OpenAIEmbeddingService: EmbeddingAPI { let apiKey: String let model: EmbeddingModel let endpoint: String + var requestModifier: ((inout URLRequest) -> Void)? = nil public func embed(text: String) async throws -> EmbeddingResponse { return try await embed(texts: [text]) @@ -23,6 +24,13 @@ struct OpenAIEmbeddingService: EmbeddingAPI { public func embed(texts text: [String]) async throws -> EmbeddingResponse { guard let url = URL(string: endpoint) else { throw ChatGPTServiceError.endpointIncorrect } + if text.isEmpty { + return .init( + data: [], + model: model.info.modelName, + usage: .init(prompt_tokens: 0, total_tokens: 0) + ) + } var request = URLRequest(url: url) request.httpMethod = "POST" let encoder = JSONEncoder() @@ -34,6 +42,8 @@ struct OpenAIEmbeddingService: EmbeddingAPI { Self.setupAppInformation(&request) Self.setupAPIKey(&request, model: model, apiKey: apiKey) + await Self.setupExtraHeaderFields(&request, model: model, apiKey: apiKey) + requestModifier?(&request) let (result, response) = try await URLSession.shared.data(for: request) guard let response = response as? HTTPURLResponse else { @@ -55,6 +65,13 @@ struct OpenAIEmbeddingService: EmbeddingAPI { public func embed(tokens: [[Int]]) async throws -> EmbeddingResponse { guard let url = URL(string: endpoint) else { throw ChatGPTServiceError.endpointIncorrect } + if tokens.isEmpty { + return .init( + data: [], + model: model.info.modelName, + usage: .init(prompt_tokens: 0, total_tokens: 0) + ) + } var request = URLRequest(url: url) request.httpMethod = "POST" let encoder = JSONEncoder() @@ -66,7 +83,8 @@ struct OpenAIEmbeddingService: EmbeddingAPI { Self.setupAppInformation(&request) Self.setupAPIKey(&request, model: model, apiKey: apiKey) - Self.setupExtraHeaderFields(&request, model: model) + await Self.setupExtraHeaderFields(&request, model: model, apiKey: apiKey) + requestModifier?(&request) let (result, response) = try await URLSession.shared.data(for: request) guard let response = response as? HTTPURLResponse else { @@ -121,16 +139,12 @@ struct OpenAIEmbeddingService: EmbeddingAPI { request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") case .azureOpenAI: request.setValue(apiKey, forHTTPHeaderField: "api-key") + case .gitHubCopilot: + break case .ollama: assertionFailure("Unsupported") } } } - - static func setupExtraHeaderFields(_ request: inout URLRequest, model: EmbeddingModel) { - for field in model.info.customHeaderInfo.headers where !field.key.isEmpty { - request.setValue(field.value, forHTTPHeaderField: field.key) - } - } } 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/ChatGPTService.swift b/Tool/Sources/OpenAIService/ChatGPTService.swift index c10cb01c..b02f4f2b 100644 --- a/Tool/Sources/OpenAIService/ChatGPTService.swift +++ b/Tool/Sources/OpenAIService/ChatGPTService.swift @@ -66,7 +66,14 @@ public struct ChatGPTError: Error, Codable, LocalizedError { public enum ChatGPTResponse: Equatable { case status([String]) case partialText(String) + case partialReasoning(String) case toolCalls([ChatMessage.ToolCall]) + case usage( + promptTokens: Int, + completionTokens: Int, + cachedTokens: Int, + otherUsage: [String: Int] + ) } public typealias ChatGPTResponseStream = AsyncThrowingStream @@ -196,6 +203,9 @@ public class ChatGPTService: ChatGPTServiceType { case let .partialText(text): continuation.yield(ChatGPTResponse.partialText(text)) + case let .partialReasoning(text): + continuation.yield(ChatGPTResponse.partialReasoning(text)) + case let .partialToolCalls(toolCalls): guard configuration.runFunctionsAutomatically else { break } var toolCallStatuses = [String: String]() { @@ -218,6 +228,20 @@ public class ChatGPTService: ChatGPTServiceType { } } } + case let .usage( + promptTokens, + completionTokens, + cachedTokens, + otherUsage + ): + continuation.yield( + .usage( + promptTokens: promptTokens, + completionTokens: completionTokens, + cachedTokens: cachedTokens, + otherUsage: otherUsage + ) + ) } } @@ -250,8 +274,15 @@ public class ChatGPTService: ChatGPTServiceType { extension ChatGPTService { enum StreamContent { + case partialReasoning(String) case partialText(String) case partialToolCalls([Int: ChatMessage.ToolCall]) + case usage( + promptTokens: Int, + completionTokens: Int, + cachedTokens: Int, + otherUsage: [String: Int] + ) } enum FunctionCallResult { @@ -340,10 +371,19 @@ extension ChatGPTService { if let content = delta.content { continuation.yield(.partialText(content)) } + + if let reasoning = delta.reasoningContent { + continuation.yield(.partialReasoning(reasoning)) + } } Logger.service.info("ChatGPT usage: \(usage)") - + continuation.yield(.usage( + promptTokens: usage.promptTokens, + completionTokens: usage.completionTokens, + cachedTokens: usage.cachedTokens, + otherUsage: usage.otherUsage + )) continuation.finish() } catch let error as CancellationError { continuation.finish(throwing: error) @@ -531,13 +571,25 @@ extension ChatGPTService { stream: Bool ) -> ChatCompletionsRequestBody { let serviceSupportsFunctionCalling = switch model.format { - case .openAI, .openAICompatible, .azureOpenAI: + case .openAI, .openAICompatible, .azureOpenAI, .gitHubCopilot: model.info.supportsFunctionCalling case .ollama, .googleAI, .claude: false } let messages = prompt.history.flatMap { chatMessage in + let images = chatMessage.images.map { image in + ChatCompletionsRequestBody.Message.Image( + base64EncodeData: image.base64EncodedData, + format: { + switch image.format { + case .png: .png + case .jpeg: .jpeg + case .gif: .gif + } + }() + ) + } var all = [ChatCompletionsRequestBody.Message]() all.append(ChatCompletionsRequestBody.Message( role: { @@ -565,7 +617,7 @@ extension ChatGPTService { nil } }(), - images: [], + images: images, audios: [], cacheIfPossible: chatMessage.cacheIfPossible )) diff --git a/Tool/Sources/OpenAIService/Configuration/ChatGPTConfiguration.swift b/Tool/Sources/OpenAIService/Configuration/ChatGPTConfiguration.swift index 3b0bd896..710a2ff0 100644 --- a/Tool/Sources/OpenAIService/Configuration/ChatGPTConfiguration.swift +++ b/Tool/Sources/OpenAIService/Configuration/ChatGPTConfiguration.swift @@ -39,7 +39,7 @@ public extension ChatGPTConfiguration { } } -public class OverridingChatGPTConfiguration: ChatGPTConfiguration { +public final class OverridingChatGPTConfiguration: ChatGPTConfiguration { public struct Overriding: Codable { public var temperature: Double? public var modelId: String? @@ -120,7 +120,7 @@ public class OverridingChatGPTConfiguration: ChatGPTConfiguration { public var apiKey: String { if let apiKey = overriding.apiKey { return apiKey } guard let name = model?.info.apiKeyName else { return configuration.apiKey } - return (try? Keychain.apiKey.get(name)) ?? configuration.apiKey + return (try? Keychain.apiKey.get(name)) ?? "" } public var shouldEndTextWindow: (String) -> Bool { diff --git a/Tool/Sources/OpenAIService/EmbeddingService.swift b/Tool/Sources/OpenAIService/EmbeddingService.swift index 0e54d3ac..d0bf1116 100644 --- a/Tool/Sources/OpenAIService/EmbeddingService.swift +++ b/Tool/Sources/OpenAIService/EmbeddingService.swift @@ -23,9 +23,14 @@ public struct EmbeddingService { ).embed(text: text) case .ollama: embeddingResponse = try await OllamaEmbeddingService( + apiKey: configuration.apiKey, model: model, endpoint: configuration.endpoint ).embed(text: text) + case .gitHubCopilot: + embeddingResponse = try await GitHubCopilotEmbeddingService( + model: model + ).embed(text: text) } #if DEBUG @@ -54,9 +59,14 @@ public struct EmbeddingService { ).embed(texts: text) case .ollama: embeddingResponse = try await OllamaEmbeddingService( + apiKey: configuration.apiKey, model: model, endpoint: configuration.endpoint ).embed(texts: text) + case .gitHubCopilot: + embeddingResponse = try await GitHubCopilotEmbeddingService( + model: model + ).embed(texts: text) } #if DEBUG @@ -85,9 +95,14 @@ public struct EmbeddingService { ).embed(tokens: tokens) case .ollama: embeddingResponse = try await OllamaEmbeddingService( + apiKey: configuration.apiKey, model: model, endpoint: configuration.endpoint ).embed(tokens: tokens) + case .gitHubCopilot: + embeddingResponse = try await GitHubCopilotEmbeddingService( + model: model + ).embed(tokens: tokens) } #if DEBUG diff --git a/Tool/Sources/OpenAIService/FucntionCall/ChatGPTFuntionProvider.swift b/Tool/Sources/OpenAIService/FucntionCall/ChatGPTFuntionProvider.swift index fd0bd460..23a9b729 100644 --- a/Tool/Sources/OpenAIService/FucntionCall/ChatGPTFuntionProvider.swift +++ b/Tool/Sources/OpenAIService/FucntionCall/ChatGPTFuntionProvider.swift @@ -6,7 +6,7 @@ public protocol ChatGPTFunctionProvider { var functionCallStrategy: FunctionCallStrategy? { get } } -extension ChatGPTFunctionProvider { +public extension ChatGPTFunctionProvider { func function(named: String) -> (any ChatGPTFunction)? { functions.first(where: { $0.name == named }) } diff --git a/Tool/Sources/OpenAIService/HeaderValueParser.swift b/Tool/Sources/OpenAIService/HeaderValueParser.swift new file mode 100644 index 00000000..0042ea75 --- /dev/null +++ b/Tool/Sources/OpenAIService/HeaderValueParser.swift @@ -0,0 +1,104 @@ +import Foundation +import GitHubCopilotService +import Logger +import Terminal + +public struct HeaderValueParser { + public enum Placeholder: String { + case gitHubCopilotOBearerToken = "github_copilot_bearer_token" + case apiKey = "api_key" + case modelName = "model_name" + } + + public struct Context { + public var modelName: String + public var apiKey: String + public var gitHubCopilotToken: () async -> GitHubCopilotExtension.Token? + public var shellEnvironmentVariable: (_ key: String) async -> String? + + public init( + modelName: String, + apiKey: String, + gitHubCopilotToken: (() async -> GitHubCopilotExtension.Token?)? = nil, + shellEnvironmentVariable: ((_: String) async -> String?)? = nil + ) { + self.modelName = modelName + self.apiKey = apiKey + self.gitHubCopilotToken = gitHubCopilotToken ?? { + try? await GitHubCopilotExtension.fetchToken() + } + self.shellEnvironmentVariable = shellEnvironmentVariable ?? { p in + let shell = ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/bash" + let terminal = Terminal() + return try? await terminal.runCommand( + shell, + arguments: ["-i", "-l", "-c", "echo $\(p)"], + environment: [:] + ) + } + } + } + + public init() {} + + /// Replace `{{PlaceHolder}}` with exact values. + public func parse(_ value: String, context: Context) async -> String { + var parsedValue = value + let placeholderRanges = findPlaceholderRanges(in: parsedValue) + + for (range, placeholderText) in placeholderRanges.reversed() { + let cleanPlaceholder = placeholderText + .trimmingCharacters(in: CharacterSet(charactersIn: "{}")) + + var replacement: String? + if let knownPlaceholder = Placeholder(rawValue: cleanPlaceholder) { + async let token = context.gitHubCopilotToken() + switch knownPlaceholder { + case .gitHubCopilotOBearerToken: + replacement = await token?.token + case .apiKey: + replacement = context.apiKey + case .modelName: + replacement = context.modelName + } + } else { + replacement = await context.shellEnvironmentVariable(cleanPlaceholder) + } + + if let replacement { + parsedValue.replaceSubrange( + range, + with: replacement.trimmingCharacters(in: .whitespacesAndNewlines) + ) + } else { + parsedValue.replaceSubrange(range, with: "none") + } + } + + return parsedValue + } + + private func findPlaceholderRanges(in string: String) -> [(Range, String)] { + var ranges: [(Range, String)] = [] + let pattern = #"\{\{[^}]+\}\}"# + + do { + let regex = try NSRegularExpression(pattern: pattern) + let matches = regex.matches( + in: string, + range: NSRange(string.startIndex..., in: string) + ) + + for match in matches { + if let range = Range(match.range, in: string) { + ranges.append((range, String(string[range]))) + } + } + } catch { + Logger.service.error("Failed to find placeholders in string: \(string)") + } + + return ranges + } +} + diff --git a/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemoryStrategy/AutoManagedChatGPTMemoryOpenAIStrategy.swift b/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemoryStrategy/AutoManagedChatGPTMemoryOpenAIStrategy.swift index 07a6bda5..a1deaed5 100644 --- a/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemoryStrategy/AutoManagedChatGPTMemoryOpenAIStrategy.swift +++ b/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemoryStrategy/AutoManagedChatGPTMemoryOpenAIStrategy.swift @@ -4,6 +4,7 @@ import Logger import TokenEncoder extension AutoManagedChatGPTMemory { + #warning("TODO: Need to fix the tokenizer or supports model specified tokenizers.") struct OpenAIStrategy: AutoManagedChatGPTMemoryStrategy { static let encoder: TokenEncoder = TiktokenCl100kBaseTokenEncoder() @@ -57,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/OpenAIService/Memory/TemplateChatGPTMemory.swift b/Tool/Sources/OpenAIService/Memory/TemplateChatGPTMemory.swift index da2205f7..2325e0c4 100644 --- a/Tool/Sources/OpenAIService/Memory/TemplateChatGPTMemory.swift +++ b/Tool/Sources/OpenAIService/Memory/TemplateChatGPTMemory.swift @@ -125,7 +125,7 @@ public struct MemoryTemplate { return formatter(list.map { $0.0 }) } } - + let composedContent = contents.joined(separator: "\n\n") if composedContent.isEmpty { return nil } @@ -162,7 +162,7 @@ public struct MemoryTemplate { _ messages: inout [Message], _ followUpMessages: inout [ChatMessage] ) async throws -> Void - + let truncateRule: TruncateRule? public init( @@ -187,7 +187,7 @@ public struct MemoryTemplate { mutating func truncate() async throws { if Task.isCancelled { return } - + if let truncateRule = truncateRule { try await truncateRule(&messages, &followUpMessages) return @@ -195,7 +195,7 @@ public struct MemoryTemplate { try await Self.defaultTruncateRule()(&messages, &followUpMessages) } - + public struct DefaultTruncateRuleOptions { public var numberOfContentListItemToKeep: (Int) -> Int = { $0 * 2 / 3 } } @@ -206,14 +206,14 @@ public struct MemoryTemplate { var options = DefaultTruncateRuleOptions() updateOptions(&options) return { messages, followUpMessages in - + // Remove the oldest followup messages when available. - + if followUpMessages.count > 20 { followUpMessages.removeFirst(followUpMessages.count / 2) return } - + if followUpMessages.count > 2 { if followUpMessages.count.isMultiple(of: 2) { followUpMessages.removeFirst(2) @@ -222,9 +222,9 @@ public struct MemoryTemplate { } return } - + // Remove according to the priority. - + var truncatingMessageIndex: Int? for (index, message) in messages.enumerated() { if message.priority == .max { continue } @@ -234,20 +234,20 @@ public struct MemoryTemplate { truncatingMessageIndex = index } } - + guard let truncatingMessageIndex else { throw CancellationError() } var truncatingMessage: Message { get { messages[truncatingMessageIndex] } set { messages[truncatingMessageIndex] = newValue } } - + if truncatingMessage.isEmpty { messages.remove(at: truncatingMessageIndex) return } - + truncatingMessage.dynamicContent.removeAll(where: { $0.isEmpty }) - + var truncatingContentIndex: Int? for (index, content) in truncatingMessage.dynamicContent.enumerated() { if content.isEmpty { continue } @@ -257,13 +257,13 @@ public struct MemoryTemplate { truncatingContentIndex = index } } - + guard let truncatingContentIndex else { throw CancellationError() } var truncatingContent: Message.DynamicContent { get { truncatingMessage.dynamicContent[truncatingContentIndex] } set { truncatingMessage.dynamicContent[truncatingContentIndex] = newValue } } - + switch truncatingContent.content { case .text: truncatingMessage.dynamicContent.remove(at: truncatingContentIndex) diff --git a/Tool/Sources/Preferences/Keys.swift b/Tool/Sources/Preferences/Keys.swift index 22aa716a..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 @@ -195,6 +200,14 @@ public extension UserDefaultPreferenceKeys { var gitHubCopilotPretendIDEToBeVSCode: PreferenceKey { .init(defaultValue: false, key: "GitHubCopilotPretendIDEToBeVSCode") } + + var gitHubCopilotModelId: PreferenceKey { + .init(defaultValue: "", key: "GitHubCopilotModelId") + } + + var gitHubCopilotModelFamily: PreferenceKey { + .init(defaultValue: "", key: "GitHubCopilotModelFamily") + } } // MARK: - Codeium Settings @@ -368,6 +381,10 @@ public extension UserDefaultPreferenceKeys { .init(defaultValue: false, key: "SuggestionWithModifierOnlyForSwift") } + var acceptSuggestionLineWithModifierControl: PreferenceKey { + .init(defaultValue: true, key: "SuggestionLineWithModifierControl") + } + var dismissSuggestionWithEsc: PreferenceKey { .init(defaultValue: true, key: "DismissSuggestionWithEsc") } @@ -470,13 +487,23 @@ public extension UserDefaultPreferenceKeys { var preferredChatModelIdForWebScope: PreferenceKey { .init(defaultValue: "", key: "PreferredChatModelIdForWebScope") } - + var preferredChatModelIdForUtilities: PreferenceKey { .init(defaultValue: "", key: "PreferredChatModelIdForUtilities") } + enum ChatPanelFloatOnTopOption: Int, Codable, Equatable { + case alwaysOnTop + case onTopWhenXcodeIsActive + case never + } + + var chatPanelFloatOnTopOption: PreferenceKey { + .init(defaultValue: .onTopWhenXcodeIsActive, key: "ChatPanelFloatOnTopOption") + } + var disableFloatOnTopWhenTheChatPanelIsDetached: PreferenceKey { - .init(defaultValue: true, key: "DisableFloatOnTopWhenTheChatPanelIsDetached") + .init(defaultValue: false, key: "DisableFloatOnTopWhenTheChatPanelIsDetached") } var keepFloatOnTopIfChatPanelAndXcodeOverlaps: PreferenceKey { @@ -486,7 +513,7 @@ public extension UserDefaultPreferenceKeys { var openChatMode: PreferenceKey> { .init(defaultValue: .init(.chatPanel), key: "DefaultOpenChatMode") } - + var legacyOpenChatMode: DeprecatedPreferenceKey { .init(defaultValue: .chatPanel, key: "OpenChatMode") } @@ -560,14 +587,49 @@ public extension UserDefaultPreferenceKeys { } } -// MARK: - Bing Search +// MARK: - Search public extension UserDefaultPreferenceKeys { - var bingSearchSubscriptionKey: PreferenceKey { + enum SearchProvider: String, Codable, CaseIterable { + 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") } - var bingSearchEndpoint: PreferenceKey { + var bingSearchEndpoint: DeprecatedPreferenceKey { .init( defaultValue: "https://api.bing.microsoft.com/v7.0/search/", key: "BingSearchEndpoint" @@ -723,7 +785,7 @@ public extension UserDefaultPreferenceKeys { var useCloudflareDomainNameForLicenseCheck: FeatureFlag { .init(defaultValue: false, key: "FeatureFlag-UseCloudflareDomainNameForLicenseCheck") } - + var doNotInstallLaunchAgentAutomatically: FeatureFlag { .init(defaultValue: false, key: "FeatureFlag-DoNotInstallLaunchAgentAutomatically") } diff --git a/Tool/Sources/Preferences/Types/ChatGPTModel.swift b/Tool/Sources/Preferences/Types/ChatGPTModel.swift index 8ac04faf..54893fb6 100644 --- a/Tool/Sources/Preferences/Types/ChatGPTModel.swift +++ b/Tool/Sources/Preferences/Types/ChatGPTModel.swift @@ -8,21 +8,19 @@ public enum ChatGPTModel: String, CaseIterable { case gpt4 = "gpt-4" case gpt432k = "gpt-4-32k" case gpt4Turbo = "gpt-4-turbo" - case gpt40314 = "gpt-4-0314" - case gpt40613 = "gpt-4-0613" - case gpt41106Preview = "gpt-4-1106-preview" case gpt4VisionPreview = "gpt-4-vision-preview" - case gpt4TurboPreview = "gpt-4-turbo-preview" - case gpt4Turbo20240409 = "gpt-4-turbo-2024-04-09" - case gpt35Turbo1106 = "gpt-3.5-turbo-1106" - case gpt35Turbo0125 = "gpt-3.5-turbo-0125" case gpt432k0314 = "gpt-4-32k-0314" case gpt432k0613 = "gpt-4-32k-0613" case gpt40125 = "gpt-4-0125-preview" + case gpt4_1 = "gpt-4.1" + case gpt4_1Mini = "gpt-4.1-mini" + case gpt4_1Nano = "gpt-4.1-nano" + case o1 = "o1" case o1Preview = "o1-preview" - case o1Preview20240912 = "o1-preview-2024-09-12" - case o1Mini = "o1-mini" - case o1Mini20240912 = "o1-mini-2024-09-12" + case o1Pro = "o1-pro" + case o3Mini = "o3-mini" + case o3 = "o3" + case o4Mini = "o4-mini" } public extension ChatGPTModel { @@ -30,55 +28,72 @@ public extension ChatGPTModel { switch self { case .gpt4: return 8192 - case .gpt40314: - return 8192 case .gpt432k: return 32768 case .gpt432k0314: return 32768 case .gpt35Turbo: return 16385 - case .gpt35Turbo1106: - return 16385 - case .gpt35Turbo0125: - return 16385 case .gpt35Turbo16k: return 16385 - case .gpt40613: - return 8192 case .gpt432k0613: return 32768 - case .gpt41106Preview: - return 128_000 case .gpt4VisionPreview: return 128_000 - case .gpt4TurboPreview: - return 128_000 case .gpt40125: return 128_000 case .gpt4Turbo: return 128_000 - case .gpt4Turbo20240409: - return 128_000 case .gpt4o: return 128_000 case .gpt4oMini: return 128_000 - case .o1Preview, .o1Preview20240912: - return 128_000 - case .o1Mini, .o1Mini20240912: + case .o1Preview: return 128_000 + case .o1: + return 200_000 + case .o3Mini: + return 200_000 + case .gpt4_1: + return 1_047_576 + case .gpt4_1Mini: + return 1_047_576 + case .gpt4_1Nano: + return 1_047_576 + case .o1Pro: + return 200_000 + case .o3: + return 200_000 + case .o4Mini: + return 200_000 } } var supportsImages: Bool { switch self { - case .gpt4VisionPreview, .gpt4Turbo, .gpt4Turbo20240409, .gpt4o, .gpt4oMini, .o1Preview, - .o1Preview20240912, .o1Mini, .o1Mini20240912: + case .gpt4VisionPreview, .gpt4Turbo, .gpt4o, .gpt4oMini, .o1Preview, .o1, .o3Mini: return true default: return false } } + + var supportsTemperature: Bool { + switch self { + case .o1Preview, .o1, .o3Mini: + return false + default: + return true + } + } + + var supportsSystemPrompt: Bool { + switch self { + case .o1Preview, .o1, .o3Mini: + return false + default: + return true + } + } } diff --git a/Tool/Sources/Preferences/Types/GoogleGenerativeChatModel.swift b/Tool/Sources/Preferences/Types/GoogleGenerativeChatModel.swift index 43e4af28..23de7f5e 100644 --- a/Tool/Sources/Preferences/Types/GoogleGenerativeChatModel.swift +++ b/Tool/Sources/Preferences/Types/GoogleGenerativeChatModel.swift @@ -1,6 +1,10 @@ import Foundation public enum GoogleGenerativeAIModel: String { + case gemini25FlashPreview = "gemini-2.5-flash-preview-04-17" + case gemini25ProPreview = "gemini-2.5-pro-preview-05-06" + case gemini20Flash = "gemini-2.0-flash" + case gemini20FlashLite = "gemini-2.0-flash-lite" case gemini15Pro = "gemini-1.5-pro" case gemini15Flash = "gemini-1.5-flash" case geminiPro = "gemini-pro" @@ -15,6 +19,14 @@ public extension GoogleGenerativeAIModel { return 1_048_576 case .gemini15Pro: return 2_097_152 + case .gemini25FlashPreview: + return 1_048_576 + case .gemini25ProPreview: + return 1_048_576 + case .gemini20Flash: + return 1_048_576 + case .gemini20FlashLite: + return 1_048_576 } } } diff --git a/Tool/Sources/PromptToCodeCustomization/PromptToCodeCustomization.swift b/Tool/Sources/PromptToCodeCustomization/PromptToCodeCustomization.swift index 46eccad2..a952311b 100644 --- a/Tool/Sources/PromptToCodeCustomization/PromptToCodeCustomization.swift +++ b/Tool/Sources/PromptToCodeCustomization/PromptToCodeCustomization.swift @@ -55,7 +55,10 @@ public protocol PromptToCodeContextInputControllerDelegate { public protocol PromptToCodeContextInputController: Perception.Perceptible { var instruction: NSAttributedString { get set } - func resolveContext(onStatusChange: @escaping ([String]) async -> Void) async -> ( + func resolveContext( + forDocumentURL: URL, + onStatusChange: @escaping ([String]) async -> Void + ) async -> ( instruction: String, references: [ChatMessage.Reference], topics: [ChatMessage.Reference], @@ -100,7 +103,10 @@ public final class DefaultPromptToCodeContextInputController: PromptToCodeContex instruction = mutable } - public func resolveContext(onStatusChange: @escaping ([String]) async -> Void) -> ( + public func resolveContext( + forDocumentURL: URL, + onStatusChange: @escaping ([String]) async -> Void + ) -> ( instruction: String, references: [ChatMessage.Reference], topics: [ChatMessage.Reference], diff --git a/Tool/Sources/SharedUIComponents/TabContainer.swift b/Tool/Sources/SharedUIComponents/TabContainer.swift index 06611861..9a61d93b 100644 --- a/Tool/Sources/SharedUIComponents/TabContainer.swift +++ b/Tool/Sources/SharedUIComponents/TabContainer.swift @@ -8,17 +8,20 @@ public final class ExternalTabContainer { public struct TabItem: Identifiable { public var id: String public var title: String + public var description: String public var image: String public let viewBuilder: () -> AnyView public init( id: String, title: String, + description: String = "", image: String = "", @ViewBuilder viewBuilder: @escaping () -> V ) { self.id = id self.title = title + self.description = description self.image = image self.viewBuilder = { AnyView(viewBuilder()) } } @@ -46,22 +49,31 @@ public final class ExternalTabContainer { public func registerTab( id: String, title: String, + description: String = "", image: String = "", @ViewBuilder viewBuilder: @escaping () -> V ) { - tabs.append(TabItem(id: id, title: title, image: image, viewBuilder: viewBuilder)) + tabs.append(TabItem( + id: id, + title: title, + description: description, + image: image, + viewBuilder: viewBuilder + )) } public static func registerTab( for tabContainerId: String, id: String, title: String, + description: String = "", image: String = "", @ViewBuilder viewBuilder: @escaping () -> V ) { tabContainer(for: tabContainerId).registerTab( id: id, title: title, + description: description, image: image, viewBuilder: viewBuilder ) 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 8518b8b0..38d6e26d 100644 --- a/Tool/Sources/SuggestionBasic/EditorInformation.swift +++ b/Tool/Sources/SuggestionBasic/EditorInformation.swift @@ -1,14 +1,14 @@ import Foundation import Parsing -public struct EditorInformation { - public struct LineAnnotation { +public struct EditorInformation: Sendable { + public struct LineAnnotation: Sendable { public var type: String public var line: Int public var message: String } - public struct SourceEditorContent { + public struct SourceEditorContent: Sendable { /// The content of the source editor. public var content: String /// The content of the source editor in lines. Every line should ends with `\n`. @@ -23,7 +23,9 @@ public struct EditorInformation { 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( max(0, range.start.line), lines.endIndex - 1 @@ -103,8 +105,12 @@ public struct EditorInformation { inside range: CursorRange, ignoreColumns: Bool = false ) -> (code: String, lines: [String]) { - guard range.start <= range.end else { return ("", []) } - + 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/SuggestionBasic/ExportedFromLSP.swift b/Tool/Sources/SuggestionBasic/ExportedFromLSP.swift index f4345fd0..52983a6b 100644 --- a/Tool/Sources/SuggestionBasic/ExportedFromLSP.swift +++ b/Tool/Sources/SuggestionBasic/ExportedFromLSP.swift @@ -1,4 +1,4 @@ -import LanguageServerProtocol +@preconcurrency import LanguageServerProtocol /// Line starts at 0. public typealias CursorPosition = LanguageServerProtocol.Position diff --git a/Tool/Sources/SuggestionBasic/LanguageIdentifierFromFilePath.swift b/Tool/Sources/SuggestionBasic/LanguageIdentifierFromFilePath.swift index a0478833..cc57246d 100644 --- a/Tool/Sources/SuggestionBasic/LanguageIdentifierFromFilePath.swift +++ b/Tool/Sources/SuggestionBasic/LanguageIdentifierFromFilePath.swift @@ -1,7 +1,7 @@ import Foundation -import LanguageServerProtocol +@preconcurrency import LanguageServerProtocol -public enum CodeLanguage: RawRepresentable, Codable, CaseIterable, Hashable { +public enum CodeLanguage: RawRepresentable, Codable, CaseIterable, Hashable, Sendable { case builtIn(LanguageIdentifier) case plaintext case other(String) 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/WebScrapper/WebScrapper.swift b/Tool/Sources/WebScrapper/WebScrapper.swift new file mode 100644 index 00000000..e7c45725 --- /dev/null +++ b/Tool/Sources/WebScrapper/WebScrapper.swift @@ -0,0 +1,161 @@ +import Foundation +import SwiftSoup +import WebKit + +@MainActor +public final class WebScrapper { + final class NavigationDelegate: NSObject, WKNavigationDelegate { + weak var scrapper: WebScrapper? + + public nonisolated func webView(_ webView: WKWebView, didFinish _: WKNavigation!) { + Task { @MainActor in + let scrollToBottomScript = "window.scrollTo(0, document.body.scrollHeight);" + _ = try? await webView.evaluateJavaScript(scrollToBottomScript) + self.scrapper?.webViewDidFinishLoading = true + } + } + + public nonisolated func webView( + _: WKWebView, + didFail _: WKNavigation!, + withError error: Error + ) { + Task { @MainActor in + self.scrapper?.navigationError = error + self.scrapper?.webViewDidFinishLoading = true + } + } + } + + public var webView: WKWebView + + var webViewDidFinishLoading = false + var navigationError: (any Error)? + let navigationDelegate: NavigationDelegate = .init() + + enum WebScrapperError: Error { + case retry + } + + public init() async { + let jsonRuleList = ###""" + [ + { + "trigger": { + "url-filter": ".*", + "resource-type": ["font"] + }, + "action": { + "type": "block" + } + }, + { + "trigger": { + "url-filter": ".*", + "resource-type": ["image"] + }, + "action": { + "type": "block" + } + }, + { + "trigger": { + "url-filter": ".*", + "resource-type": ["media"] + }, + "action": { + "type": "block" + } + } + ] + """### + + let list = try? await WKContentRuleListStore.default().compileContentRuleList( + forIdentifier: "web-scrapping", + encodedContentRuleList: jsonRuleList + ) + + let configuration = WKWebViewConfiguration() + if let list { + configuration.userContentController.add(list) + } + configuration.allowsAirPlayForMediaPlayback = false + configuration.mediaTypesRequiringUserActionForPlayback = .all + configuration.defaultWebpagePreferences.preferredContentMode = .desktop + configuration.defaultWebpagePreferences.allowsContentJavaScript = true + configuration.websiteDataStore = .nonPersistent() + configuration.applicationNameForUserAgent = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0.1 Safari/605.1.15" + + if #available(iOS 17.0, macOS 14.0, *) { + configuration.allowsInlinePredictions = false + } + + // The web page need the web view to have a size to load correctly. + let webView = WKWebView( + frame: .init(x: 0, y: 0, width: 800, height: 5000), + configuration: configuration + ) + self.webView = webView + navigationDelegate.scrapper = self + webView.navigationDelegate = navigationDelegate + } + + public func fetch( + url: URL, + validate: @escaping (SwiftSoup.Document) -> Bool = { _ in true }, + timeout: TimeInterval = 15, + retryLimit: Int = 50 + ) async throws -> String { + webViewDidFinishLoading = false + navigationError = nil + var retryCount = 0 + _ = webView.load(.init(url: url)) + while !webViewDidFinishLoading { + try await Task.sleep(nanoseconds: 10_000_000) + } + let deadline = Date().addingTimeInterval(timeout) + if let navigationError { throw navigationError } + while retryCount < retryLimit, Date() < deadline { + if let html = try? await getHTML(), !html.isEmpty, + let document = try? SwiftSoup.parse(html, url.path), + validate(document) + { + return html + } + retryCount += 1 + try await Task.sleep(nanoseconds: 100_000_000) + } + + enum Error: Swift.Error, LocalizedError { + case failToValidate + + var errorDescription: String? { + switch self { + case .failToValidate: + return "Failed to validate the HTML content within the given timeout and retry limit." + } + } + } + throw Error.failToValidate + } + + func getHTML() async throws -> String { + do { + let isReady = try await webView.evaluateJavaScript(checkIfReady) as? Bool ?? false + if !isReady { throw WebScrapperError.retry } + return try await webView.evaluateJavaScript(getHTMLText) as? String ?? "" + } catch { + throw WebScrapperError.retry + } + } +} + +private let getHTMLText = """ +document.documentElement.outerHTML; +""" + +private let checkIfReady = """ +document.readyState === "ready" || document.readyState === "complete"; +""" + diff --git a/Tool/Sources/WebSearchService/SearchServices/AppleDocumentationSearchService.swift b/Tool/Sources/WebSearchService/SearchServices/AppleDocumentationSearchService.swift new file mode 100644 index 00000000..680c4fb6 --- /dev/null +++ b/Tool/Sources/WebSearchService/SearchServices/AppleDocumentationSearchService.swift @@ -0,0 +1,60 @@ +import Foundation +import SwiftSoup +import WebKit +import WebScrapper + +struct AppleDocumentationSearchService: SearchService { + func search(query: String) async throws -> WebSearchResult { + let queryEncoded = query + .addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" + let url = URL(string: "https://developer.apple.com/search/?q=\(queryEncoded)")! + + let scrapper = await WebScrapper() + let html = try await scrapper.fetch(url: url) { document in + DeveloperDotAppleResultParser.validate(document: document) + } + + return try DeveloperDotAppleResultParser.parse(html: html) + } +} + +enum DeveloperDotAppleResultParser { + static func validate(document: SwiftSoup.Document) -> Bool { + guard let _ = try? document.select("ul.search-results").first + else { return false } + return true + } + + static func parse(html: String) throws -> WebSearchResult { + let document = try SwiftSoup.parse(html) + let searchResult = try? document.select("ul.search-results").first + + guard let searchResult else { return .init(webPages: []) } + + var results: [WebSearchResult.WebPage] = [] + for element in searchResult.children() { + if let titleElement = try? element.select("p.result-title"), + let link = try? titleElement.select("a").attr("href"), + !link.isEmpty + { + let title = (try? titleElement.text()) ?? "" + let snippet = (try? element.select("p.result-description").text()) + ?? (try? element.select("ul.breadcrumb-list").text()) + ?? "" + results.append(WebSearchResult.WebPage( + urlString: { + if link.hasPrefix("/") { + return "https://developer.apple.com\(link)" + } + return link + }(), + title: title, + snippet: snippet + )) + } + } + + return WebSearchResult(webPages: results) + } +} + diff --git a/Tool/Sources/BingSearchService/BingSearchService.swift b/Tool/Sources/WebSearchService/SearchServices/BingSearchService.swift similarity index 68% rename from Tool/Sources/BingSearchService/BingSearchService.swift rename to Tool/Sources/WebSearchService/SearchServices/BingSearchService.swift index e185d268..0f373168 100644 --- a/Tool/Sources/BingSearchService/BingSearchService.swift +++ b/Tool/Sources/WebSearchService/SearchServices/BingSearchService.swift @@ -1,19 +1,19 @@ import Foundation -public struct BingSearchResult: Codable { - public var webPages: WebPages +struct BingSearchResult: Codable { + var webPages: WebPages - public struct WebPages: Codable { - public var webSearchUrl: String - public var totalEstimatedMatches: Int - public var value: [WebPageValue] + struct WebPages: Codable { + var webSearchUrl: String + var totalEstimatedMatches: Int + var value: [WebPageValue] - public struct WebPageValue: Codable { - public var id: String - public var name: String - public var url: String - public var displayUrl: String - public var snippet: String + struct WebPageValue: Codable { + var id: String + var name: String + var url: String + var displayUrl: String + var snippet: String } } } @@ -42,16 +42,27 @@ enum BingSearchError: Error, LocalizedError { } } -public struct BingSearchService { - public var subscriptionKey: String - public var searchURL: String +struct BingSearchService: SearchService { + var subscriptionKey: String + var searchURL: String - public init(subscriptionKey: String, searchURL: String) { + init(subscriptionKey: String, searchURL: String) { self.subscriptionKey = subscriptionKey self.searchURL = searchURL } - public func search( + func search(query: String) async throws -> WebSearchResult { + let result = try await search(query: query, numberOfResult: 10) + return WebSearchResult(webPages: result.webPages.value.map { + WebSearchResult.WebPage( + urlString: $0.url, + title: $0.name, + snippet: $0.snippet + ) + }) + } + + func search( query: String, numberOfResult: Int, freshness: String? = nil diff --git a/Tool/Sources/WebSearchService/SearchServices/HeadlessBrowserSearchService.swift b/Tool/Sources/WebSearchService/SearchServices/HeadlessBrowserSearchService.swift new file mode 100644 index 00000000..949004ca --- /dev/null +++ b/Tool/Sources/WebSearchService/SearchServices/HeadlessBrowserSearchService.swift @@ -0,0 +1,285 @@ +import Foundation +import SwiftSoup +import WebKit +import WebScrapper + +struct HeadlessBrowserSearchService: SearchService { + let engine: WebSearchProvider.HeadlessBrowserEngine + + func search(query: String) async throws -> WebSearchResult { + let queryEncoded = query + .addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" + let url = switch engine { + case .google: + URL(string: "https://www.google.com/search?q=\(queryEncoded)")! + case .baidu: + URL(string: "https://www.baidu.com/s?wd=\(queryEncoded)")! + case .duckDuckGo: + URL(string: "https://duckduckgo.com/?q=\(queryEncoded)")! + case .bing: + URL(string: "https://www.bing.com/search?q=\(queryEncoded)")! + } + + let scrapper = await WebScrapper() + let html = try await scrapper.fetch(url: url) { document in + switch engine { + case .google: + return GoogleSearchResultParser.validate(document: document) + case .baidu: + return BaiduSearchResultParser.validate(document: document) + case .duckDuckGo: + return DuckDuckGoSearchResultParser.validate(document: document) + case .bing: + return BingSearchResultParser.validate(document: document) + } + } + + switch engine { + case .google: + return try GoogleSearchResultParser.parse(html: html) + case .baidu: + return await BaiduSearchResultParser.parse(html: html) + case .duckDuckGo: + return DuckDuckGoSearchResultParser.parse(html: html) + case .bing: + return BingSearchResultParser.parse(html: html) + } + } +} + +enum GoogleSearchResultParser { + static func validate(document: SwiftSoup.Document) -> Bool { + guard let _ = try? document.select("#rso").first + else { return false } + return true + } + + static func parse(html: String) throws -> WebSearchResult { + let document = try SwiftSoup.parse(html) + let searchResult = try document.select("#rso").first + + guard let searchResult else { return .init(webPages: []) } + + var results: [WebSearchResult.WebPage] = [] + for element in searchResult.children() { + if let title = try? element.select("h3").text(), + let link = try? element.select("a").attr("href"), + !link.isEmpty, + // A magic class name. + let snippet = try? element.select("div.VwiC3b").first()?.text() + ?? element.select("span.st").first()?.text() + { + results.append(WebSearchResult.WebPage( + urlString: link, + title: title, + snippet: snippet + )) + } + } + + return WebSearchResult(webPages: results) + } +} + +enum BaiduSearchResultParser { + static func validate(document: SwiftSoup.Document) -> Bool { + return (try? document.select("#content_left").first()) != nil + } + + static func getRealLink(from baiduLink: String) async -> String { + guard let url = URL(string: baiduLink) else { + return baiduLink + } + + let config = URLSessionConfiguration.default + config.httpShouldSetCookies = true + config.httpCookieAcceptPolicy = .always + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.addValue( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15", + forHTTPHeaderField: "User-Agent" + ) + + let redirectCapturer = RedirectCapturer() + let session = URLSession( + configuration: config, + delegate: redirectCapturer, + delegateQueue: nil + ) + + do { + let _ = try await session.data(for: request) + + if let finalURL = redirectCapturer.finalURL { + return finalURL.absoluteString + } + + return baiduLink + } catch { + return baiduLink + } + } + + class RedirectCapturer: NSObject, URLSessionTaskDelegate { + var finalURL: URL? + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void + ) { + finalURL = request.url + completionHandler(request) + } + } + + static func parse(html: String) async -> WebSearchResult { + let document = try? SwiftSoup.parse(html) + let elements = try? document?.select("#content_left").first()?.children() + + var results: [WebSearchResult.WebPage] = [] + if let elements = elements { + for element in elements { + if let titleElement = try? element.select("h3").first(), + let link = try? element.select("a").attr("href"), + link.hasPrefix("http") + { + let realLink = await getRealLink(from: link) + let title = (try? titleElement.text()) ?? "" + let snippet = { + let abstract = try? element.select("div[data-module=\"abstract\"]").text() + if let abstract, !abstract.isEmpty { + return abstract + } + return (try? titleElement.nextElementSibling()?.text()) ?? "" + }() + results.append(WebSearchResult.WebPage( + urlString: realLink, + title: title, + snippet: snippet + )) + } + } + } + + return WebSearchResult(webPages: results) + } +} + +enum DuckDuckGoSearchResultParser { + static func validate(document: SwiftSoup.Document) -> Bool { + return (try? document.select(".react-results--main").first()) != nil + } + + static func parse(html: String) -> WebSearchResult { + let document = try? SwiftSoup.parse(html) + let body = document?.body() + + var results: [WebSearchResult.WebPage] = [] + + if let reactResults = try? body?.select(".react-results--main") { + for object in reactResults { + for element in object.children() { + if let linkElement = try? element.select("a[data-testid=\"result-title-a\"]"), + let link = try? linkElement.attr("href"), + link.hasPrefix("http"), + let titleElement = try? element.select("span").first() + { + let title = (try? titleElement.select("span").first()?.text()) ?? "" + let snippet = ( + try? element.select("[data-result=snippet]").first()?.text() + ) ?? "" + + results.append(WebSearchResult.WebPage( + urlString: link, + title: title, + snippet: snippet + )) + } + } + } + } + + return WebSearchResult(webPages: results) + } +} + +enum BingSearchResultParser { + static func validate(document: SwiftSoup.Document) -> Bool { + return (try? document.select("#b_results").first()) != nil + } + + static func getRealLink(from bingLink: String) -> String { + guard let url = URL(string: bingLink) else { return bingLink } + + if let components = URLComponents(url: url, resolvingAgainstBaseURL: true), + let queryItems = components.queryItems, + var uParam = queryItems.first(where: { $0.name == "u" })?.value + { + if uParam.hasPrefix("a1aHR") { + uParam.removeFirst() + uParam.removeFirst() + } + + func decode() -> String? { + guard let decodedData = Data(base64Encoded: uParam), + let decodedString = String(data: decodedData, encoding: .utf8) + else { return nil } + return decodedString + } + + if let decodedString = decode() { + return decodedString + } + uParam += "=" + if let decodedString = decode() { + return decodedString + } + uParam += "=" + if let decodedString = decode() { + return decodedString + } + } + + return bingLink + } + + static func parse(html: String) -> WebSearchResult { + let document = try? SwiftSoup.parse(html) + let searchResults = try? document?.select("#b_results").first() + + var results: [WebSearchResult.WebPage] = [] + if let elements = try? searchResults?.select("li.b_algo") { + for element in elements { + if let titleElement = try? element.select("h2").first(), + let linkElement = try? titleElement.select("a").first(), + let link = try? linkElement.attr("href"), + link.hasPrefix("http") + { + let link = getRealLink(from: link) + let title = (try? titleElement.text()) ?? "" + let snippet = { + if let it = try? element.select(".b_caption p").first()?.text(), + !it.isEmpty { return it } + if let it = try? element.select(".b_lineclamp2").first()?.text(), + !it.isEmpty { return it } + return (try? element.select("p").first()?.text()) ?? "" + }() + + results.append(WebSearchResult.WebPage( + urlString: link, + title: title, + snippet: snippet + )) + } + } + } + + return WebSearchResult(webPages: results) + } +} + diff --git a/Tool/Sources/WebSearchService/SearchServices/SerpAPISearchService.swift b/Tool/Sources/WebSearchService/SearchServices/SerpAPISearchService.swift new file mode 100644 index 00000000..0fa7a1ee --- /dev/null +++ b/Tool/Sources/WebSearchService/SearchServices/SerpAPISearchService.swift @@ -0,0 +1,67 @@ +import Foundation + +struct SerpAPIResponse: Codable { + var organic_results: [OrganicResult] + + struct OrganicResult: Codable { + var position: Int? + var title: String? + var link: String? + var snippet: String? + + func toWebSearchResult() -> WebSearchResult.WebPage? { + guard let link, let title else { return nil } + return WebSearchResult.WebPage(urlString: link, title: title, snippet: snippet ?? "") + } + } + + func toWebSearchResult() -> WebSearchResult { + return WebSearchResult(webPages: organic_results.compactMap { $0.toWebSearchResult() }) + } +} + +struct SerpAPISearchService: SearchService { + let engine: WebSearchProvider.SerpAPIEngine + let endpoint: URL = .init(string: "https://serpapi.com/search.json")! + let apiKey: String + + init(engine: WebSearchProvider.SerpAPIEngine, apiKey: String) { + self.engine = engine + self.apiKey = apiKey + } + + func search(query: String) async throws -> WebSearchResult { + var request = URLRequest(url: endpoint) + request.httpMethod = "GET" + var urlComponents = URLComponents(url: endpoint, resolvingAgainstBaseURL: true)! + urlComponents.queryItems = [ + URLQueryItem(name: "q", value: query), + URLQueryItem(name: "engine", value: engine.rawValue), + URLQueryItem(name: "api_key", value: apiKey) + ] + + guard let url = urlComponents.url else { + throw URLError(.badURL) + } + + request = URLRequest(url: url) + request.httpMethod = "GET" + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + throw URLError(.badServerResponse) + } + + // Parse the response into WebSearchResult + let decoder = JSONDecoder() + + do { + let searchResponse = try decoder.decode(SerpAPIResponse.self, from: data) + return searchResponse.toWebSearchResult() + } catch { + throw error + } + } +} + diff --git a/Tool/Sources/WebSearchService/WebSearchService.swift b/Tool/Sources/WebSearchService/WebSearchService.swift new file mode 100644 index 00000000..7eceade4 --- /dev/null +++ b/Tool/Sources/WebSearchService/WebSearchService.swift @@ -0,0 +1,75 @@ +import Foundation +import Preferences +import Keychain + +public enum WebSearchProvider { + public enum SerpAPIEngine: String { + case google + case baidu + case bing + case duckDuckGo = "duckduckgo" + } + + public enum HeadlessBrowserEngine: String { + case google + case baidu + case bing + case duckDuckGo = "duckduckgo" + } + + case serpAPI(SerpAPIEngine, apiKey: String) + case headlessBrowser(HeadlessBrowserEngine) + case appleDocumentation + + public static var userPreferred: WebSearchProvider { + switch UserDefaults.shared.value(for: \.searchProvider) { + case .headlessBrowser: + return .headlessBrowser(.init( + rawValue: UserDefaults.shared.value(for: \.headlessBrowserEngine).rawValue + ) ?? .google) + case .serpAPI: + let apiKeyName = UserDefaults.shared.value(for: \.serpAPIKeyName) + return .serpAPI(.init( + rawValue: UserDefaults.shared.value(for: \.serpAPIEngine).rawValue + ) ?? .google, apiKey: (try? Keychain.apiKey.get(apiKeyName)) ?? "") + } + } +} + +public struct WebSearchResult: Equatable { + public struct WebPage: Equatable { + public var urlString: String + public var title: String + public var snippet: String + } + + public var webPages: [WebPage] +} + +public protocol SearchService { + func search(query: String) async throws -> WebSearchResult +} + +public struct WebSearchService { + let service: SearchService + + init(service: SearchService) { + self.service = service + } + + public init(provider: WebSearchProvider) { + switch provider { + case let .serpAPI(engine, apiKey): + service = SerpAPISearchService(engine: engine, apiKey: apiKey) + case let .headlessBrowser(engine): + service = HeadlessBrowserSearchService(engine: engine) + case .appleDocumentation: + service = AppleDocumentationSearchService() + } + } + + public func search(query: String) async throws -> WebSearchResult { + return try await service.search(query: query) + } +} + diff --git a/Tool/Sources/Workspace/Filespace.swift b/Tool/Sources/Workspace/Filespace.swift index d521bdbf..be508239 100644 --- a/Tool/Sources/Workspace/Filespace.swift +++ b/Tool/Sources/Workspace/Filespace.swift @@ -62,7 +62,7 @@ public struct FilespaceCodeMetadata: Equatable { } @dynamicMemberLookup -public final class Filespace { +public final class Filespace: @unchecked Sendable { struct GitIgnoreStatus { var isIgnored: Bool var checkTime: Date @@ -88,7 +88,12 @@ public final class Filespace { } 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/Workspace.swift b/Tool/Sources/Workspace/Workspace.swift index cb57f47d..e7dc9d0e 100644 --- a/Tool/Sources/Workspace/Workspace.swift +++ b/Tool/Sources/Workspace/Workspace.swift @@ -49,7 +49,7 @@ open class WorkspacePlugin { } @dynamicMemberLookup -public final class Workspace { +public final class Workspace: @unchecked Sendable { public struct UnsupportedFileError: Error, LocalizedError { public var extensionName: String public var errorDescription: String? { diff --git a/Tool/Sources/Workspace/WorkspacePool.swift b/Tool/Sources/Workspace/WorkspacePool.swift index 8188671b..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 @@ -68,6 +77,11 @@ public class WorkspacePool { } return nil } + + @WorkspaceActor + public func destroy() { + workspaces = [:] + } @WorkspaceActor public func fetchOrCreateWorkspace(workspaceURL: URL) async throws -> Workspace { @@ -92,7 +106,7 @@ public class WorkspacePool { -> (workspace: Workspace, filespace: Filespace) { // If we can get the workspace URL directly. - if let currentWorkspaceURL = await XcodeInspector.shared.safe.realtimeActiveWorkspaceURL { + if let currentWorkspaceURL = XcodeInspector.shared.realtimeActiveWorkspaceURL { if let existed = workspaces[currentWorkspaceURL] { // Reuse the existed workspace. let filespace = try existed.createFilespaceIfNeeded( diff --git a/Tool/Sources/XPCShared/XPCServiceProtocol.swift b/Tool/Sources/XPCShared/XPCServiceProtocol.swift index 4f0c1fee..ec5aea50 100644 --- a/Tool/Sources/XPCShared/XPCServiceProtocol.swift +++ b/Tool/Sources/XPCShared/XPCServiceProtocol.swift @@ -80,7 +80,7 @@ public enum ExtensionServiceRequests { public struct ServiceInfo: Codable { public var bundleIdentifier: String public var name: String - + public init(bundleIdentifier: String, name: String) { self.bundleIdentifier = bundleIdentifier self.name = name @@ -92,14 +92,14 @@ public enum ExtensionServiceRequests { public init() {} } - + public struct GetExtensionOpenChatHandlers: ExtensionServiceRequestType { public struct HandlerInfo: Codable { public var bundleIdentifier: String public var id: String public var tabName: String public var isBuiltIn: Bool - + public init(bundleIdentifier: String, id: String, tabName: String, isBuiltIn: Bool) { self.bundleIdentifier = bundleIdentifier self.id = id @@ -113,6 +113,18 @@ public enum ExtensionServiceRequests { public init() {} } + + public struct GetSuggestionLineAcceptedCode: ExtensionServiceRequestType { + public typealias ResponseBody = UpdatedContent? + + public static let endpoint = "GetSuggestionLineAcceptedCode" + + public let editorContent: EditorContent + + public init(editorContent: EditorContent) { + self.editorContent = editorContent + } + } } public struct XPCRequestHandlerHitError: Error, LocalizedError { diff --git a/Tool/Sources/XcodeInspector/AppInstanceInspector.swift b/Tool/Sources/XcodeInspector/AppInstanceInspector.swift index 6e04309a..cd14dc13 100644 --- a/Tool/Sources/XcodeInspector/AppInstanceInspector.swift +++ b/Tool/Sources/XcodeInspector/AppInstanceInspector.swift @@ -1,8 +1,8 @@ import AppKit import Foundation -public class AppInstanceInspector: ObservableObject { - let runningApplication: NSRunningApplication +open class AppInstanceInspector: @unchecked Sendable { + 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 3150ed49..33291631 100644 --- a/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift +++ b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift @@ -1,17 +1,20 @@ -import AppKit +@preconcurrency import AppKit import AsyncPassthroughSubject import AXExtension import AXNotificationStream import Combine import Foundation +import Perception -public final class XcodeAppInstanceInspector: AppInstanceInspector { - public struct AXNotification { +@XcodeInspectorActor +@Perceptible +public final class XcodeAppInstanceInspector: AppInstanceInspector, @unchecked Sendable { + public struct AXNotification: Sendable { public var kind: AXNotificationKind public var element: AXUIElement } - public enum AXNotificationKind { + public enum AXNotificationKind: Sendable { case titleChanged case applicationActivated case applicationDeactivated @@ -64,20 +67,70 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { } } - @Published public fileprivate(set) var focusedWindow: XcodeWindowInspector? - @Published public fileprivate(set) var documentURL: URL? = nil - @Published public fileprivate(set) var workspaceURL: URL? = nil - @Published public fileprivate(set) var projectRootURL: URL? = nil - @Published public fileprivate(set) var workspaces = [WorkspaceIdentifier: Workspace]() - @Published public private(set) var completionPanel: AXUIElement? - public var realtimeWorkspaces: [WorkspaceIdentifier: WorkspaceInfo] { - updateWorkspaceInfo() - return workspaces.mapValues(\.info) + @MainActor + public fileprivate(set) var focusedWindow: XcodeWindowInspector? { + didSet { + if runningApplication.isActive { + NotificationCenter.default.post(name: .focusedWindowDidChange, object: self) + } + } + } + + @MainActor + public fileprivate(set) var documentURL: URL? = nil { + didSet { + if runningApplication.isActive { + NotificationCenter.default.post(name: .activeDocumentURLDidChange, object: self) + } + } + } + + @MainActor + public fileprivate(set) var workspaceURL: URL? = nil { + didSet { + if runningApplication.isActive { + NotificationCenter.default.post(name: .activeWorkspaceURLDidChange, object: self) + } + } + } + + @MainActor + public fileprivate(set) var projectRootURL: URL? = nil { + didSet { + if runningApplication.isActive { + NotificationCenter.default.post(name: .activeProjectRootURLDidChange, object: self) + } + } + } + + @MainActor + public fileprivate(set) var workspaces = [WorkspaceIdentifier: Workspace]() { + didSet { + if runningApplication.isActive { + NotificationCenter.default.post(name: .xcodeWorkspacesDidChange, object: self) + } + } + } + + @MainActor + public private(set) var completionPanel: AXUIElement? { + didSet { + if runningApplication.isActive { + NotificationCenter.default.post(name: .completionPanelDidChange, object: self) + } + } + } + + private let observer = XcodeInspector.createObserver() + + public nonisolated var realtimeWorkspaces: [WorkspaceIdentifier: WorkspaceInfo] { + Self.fetchVisibleWorkspaces(runningApplication).mapValues { $0.info } } - public let axNotifications = AsyncPassthroughSubject() + public nonisolated let axNotifications = AsyncPassthroughSubject() - public var realtimeDocumentURL: URL? { + public nonisolated + var realtimeDocumentURL: URL? { guard let window = appElement.focusedWindow, window.identifier == "Xcode.WorkspaceWindow" else { return nil } @@ -85,7 +138,8 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { return WorkspaceXcodeWindowInspector.extractDocumentURL(windowElement: window) } - public var realtimeWorkspaceURL: URL? { + public nonisolated + var realtimeWorkspaceURL: URL? { guard let window = appElement.focusedWindow, window.identifier == "Xcode.WorkspaceWindow" else { return nil } @@ -93,7 +147,8 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { return WorkspaceXcodeWindowInspector.extractWorkspaceURL(windowElement: window) } - public var realtimeProjectURL: URL? { + public nonisolated + var realtimeProjectURL: URL? { let workspaceURL = realtimeWorkspaceURL let documentURL = realtimeDocumentURL return WorkspaceXcodeWindowInspector.extractProjectURL( @@ -122,8 +177,7 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { return result } - private var longRunningTasks = Set>() - private var focusedWindowObservations = Set() + @PerceptionIgnored private var longRunningTasks = Set>() deinit { axNotifications.finish() @@ -134,27 +188,28 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { super.init(runningApplication: runningApplication) Task { @XcodeInspectorActor in - observeFocusedWindow() + await observeFocusedWindow() observeAXNotifications() try await Task.sleep(nanoseconds: 3_000_000_000) // Sometimes the focused window may not be ready on app launch. - if !(focusedWindow is WorkspaceXcodeWindowInspector) { - observeFocusedWindow() + if await !(focusedWindow is WorkspaceXcodeWindowInspector) { + await observeFocusedWindow() } } } - @XcodeInspectorActor func refresh() { - if let focusedWindow = focusedWindow as? WorkspaceXcodeWindowInspector { - focusedWindow.refresh() - } else { - observeFocusedWindow() + Task { @MainActor in + if let focusedWindow = focusedWindow as? WorkspaceXcodeWindowInspector { + await focusedWindow.refresh() + } else { + observeFocusedWindow() + } } } - @XcodeInspectorActor + @MainActor private func observeFocusedWindow() { if let window = appElement.focusedWindow { if window.identifier == "Xcode.WorkspaceWindow" { @@ -164,49 +219,40 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { axNotifications: axNotifications ) - focusedWindowObservations.forEach { $0.cancel() } - focusedWindowObservations.removeAll() + focusedWindow = window + documentURL = window.documentURL + workspaceURL = window.workspaceURL + projectRootURL = window.projectRootURL - Task { @MainActor in - focusedWindow = window - documentURL = window.documentURL - workspaceURL = window.workspaceURL - projectRootURL = window.projectRootURL + observer.observe { [weak self] in + let url = window.documentURL + if url != .init(fileURLWithPath: "/") { + self?.documentURL = url + } } - window.$documentURL - .filter { $0 != .init(fileURLWithPath: "/") } - .receive(on: DispatchQueue.main) - .sink { [weak self] url in - self?.documentURL = url - }.store(in: &focusedWindowObservations) - window.$workspaceURL - .filter { $0 != .init(fileURLWithPath: "/") } - .receive(on: DispatchQueue.main) - .sink { [weak self] url in + observer.observe { [weak self] in + let url = window.workspaceURL + if url != .init(fileURLWithPath: "/") { self?.workspaceURL = url - }.store(in: &focusedWindowObservations) - window.$projectRootURL - .filter { $0 != .init(fileURLWithPath: "/") } - .receive(on: DispatchQueue.main) - .sink { [weak self] url in - self?.projectRootURL = url - }.store(in: &focusedWindowObservations) + } + } - } else { - let window = XcodeWindowInspector(uiElement: window) - Task { @MainActor in - focusedWindow = window + observer.observe { [weak self] in + let url = window.projectRootURL + if url != .init(fileURLWithPath: "/") { + self?.projectRootURL = url + } } + } else { + let window = XcodeWindowInspector(app: runningApplication, uiElement: window) + focusedWindow = window } } else { - Task { @MainActor in - focusedWindow = nil - } + focusedWindow = nil } } - @XcodeInspectorActor func observeAXNotifications() { longRunningTasks.forEach { $0.cancel() } longRunningTasks = [] @@ -245,7 +291,7 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { self.axNotifications.send(.init(kind: event, element: notification.element)) if event == .focusedWindowChanged { - observeFocusedWindow() + await observeFocusedWindow() } if event == .focusedUIElementChanged || event == .applicationDeactivated { @@ -254,7 +300,7 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { guard let self else { return } try await Task.sleep(nanoseconds: 2_000_000_000) try Task.checkCancellation() - self.updateWorkspaceInfo() + await self.updateWorkspaceInfo() } } @@ -265,16 +311,27 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { await MainActor.run { self.completionPanel = notification.element self.completionPanel?.setMessagingTimeout(1) + } + self.axNotifications.send(.init( + kind: .xcodeCompletionPanelChanged, + element: notification.element + )) + } + case .uiElementDestroyed: + 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 )) - } - } - case .uiElementDestroyed: - if isCompletionPanel(notification.element) { - await MainActor.run { - self.completionPanel = nil + } else if completionPanel.parent == nil { + await MainActor.run { + self.completionPanel = nil + } self.axNotifications.send(.init( kind: .xcodeCompletionPanelChanged, element: notification.element @@ -289,14 +346,16 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { longRunningTasks.insert(observeAXNotificationTask) - updateWorkspaceInfo() + Task { @MainActor in + updateWorkspaceInfo() + } } } // MARK: - Workspace Info extension XcodeAppInstanceInspector { - public enum WorkspaceIdentifier: Hashable { + public enum WorkspaceIdentifier: Hashable, Sendable { case url(URL) case unknown } @@ -318,7 +377,7 @@ extension XcodeAppInstanceInspector { } } - public struct WorkspaceInfo { + public struct WorkspaceInfo: Sendable { public let tabs: Set public func combined(with info: WorkspaceInfo) -> WorkspaceInfo { @@ -326,16 +385,31 @@ extension XcodeAppInstanceInspector { } } + @MainActor func updateWorkspaceInfo() { let workspaceInfoInVisibleSpace = Self.fetchVisibleWorkspaces(runningApplication) let workspaces = Self.updateWorkspace(workspaces, with: workspaceInfoInVisibleSpace) - Task { @MainActor in - self.workspaces = workspaces + 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. - static func workspaceIdentifier(_ window: AXUIElement) -> WorkspaceIdentifier { + nonisolated static func workspaceIdentifier(_ window: AXUIElement) -> WorkspaceIdentifier { if let url = WorkspaceXcodeWindowInspector.extractWorkspaceURL(windowElement: window) { return WorkspaceIdentifier.url(url) } @@ -343,7 +417,7 @@ extension XcodeAppInstanceInspector { } /// With Accessibility API, we can ONLY get the information of visible windows. - static func fetchVisibleWorkspaces( + nonisolated static func fetchVisibleWorkspaces( _ app: NSRunningApplication ) -> [WorkspaceIdentifier: Workspace] { let app = AXUIElementCreateApplication(app.processIdentifier) @@ -367,7 +441,7 @@ extension XcodeAppInstanceInspector { allTabs.insert(element.title) return .skipDescendants } - return .continueSearching + return .continueSearching(()) } } return allTabs @@ -380,7 +454,7 @@ extension XcodeAppInstanceInspector { return dict } - static func updateWorkspace( + nonisolated static func updateWorkspace( _ old: [WorkspaceIdentifier: Workspace], with new: [WorkspaceIdentifier: Workspace] ) -> [WorkspaceIdentifier: Workspace] { @@ -422,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 @@ -437,12 +528,12 @@ public extension AXUIElement { tabBars.append(element) return .stopSearching } - return .continueSearching + return .continueSearching(()) } return .skipDescendantsAndSiblings } - + if element.identifier == "editor context" { return .skipDescendantsAndSiblings } @@ -458,30 +549,27 @@ public extension AXUIElement { if description == "Debug Area" { return .skipDescendants } - + if description == "debug bar" { 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 } - + if element.identifier == "editor context" { return .skipDescendantsAndSiblings } @@ -498,14 +586,15 @@ public extension AXUIElement { debugArea = element return .skipDescendants } - + if description == "debug bar" { return .skipDescendants } - return .continueSearching + return .continueSearching(()) } return debugArea } } + diff --git a/Tool/Sources/XcodeInspector/SourceEditor.swift b/Tool/Sources/XcodeInspector/SourceEditor.swift index 09c7da04..6ddd5b95 100644 --- a/Tool/Sources/XcodeInspector/SourceEditor.swift +++ b/Tool/Sources/XcodeInspector/SourceEditor.swift @@ -1,4 +1,4 @@ -import AppKit +@preconcurrency import AppKit import AsyncPassthroughSubject import AXNotificationStream import Foundation @@ -6,10 +6,10 @@ import Logger import SuggestionBasic /// Representing a source editor inside Xcode. -public class SourceEditor { +public class SourceEditor: @unchecked Sendable { public typealias Content = EditorInformation.SourceEditorContent - public struct AXNotification: Hashable { + public struct AXNotification: Hashable, Sendable { public var kind: AXNotificationKind public var element: AXUIElement @@ -18,7 +18,7 @@ public class SourceEditor { } } - public enum AXNotificationKind: Hashable, Equatable { + public enum AXNotificationKind: Hashable, Equatable, Sendable { case selectedTextChanged case valueChanged case scrollPositionChanged @@ -82,7 +82,7 @@ public class SourceEditor { private func observeAXNotifications() { observeAXNotificationsTask?.cancel() - observeAXNotificationsTask = Task { @XcodeInspectorActor [weak self] in + observeAXNotificationsTask = Task { [weak self] in guard let self else { return } await withThrowingTaskGroup(of: Void.self) { [weak self] group in guard let self else { return } @@ -262,6 +262,7 @@ public extension SourceEditor { countE += line.utf16.count } if cursorRange.end == .outOfScope { + if range.lowerBound == range.upperBound { return .outOfScope } cursorRange.end = .init( line: lines.endIndex - 1, character: lines.last?.utf16.count ?? 0 diff --git a/Tool/Sources/XcodeInspector/XcodeInspector.swift b/Tool/Sources/XcodeInspector/XcodeInspector.swift index 51defded..c26b7c7b 100644 --- a/Tool/Sources/XcodeInspector/XcodeInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeInspector.swift @@ -4,12 +4,40 @@ import AXExtension import Combine import Foundation import Logger +import Perception import Preferences import SuggestionBasic +import SwiftNavigation import Toast public extension Notification.Name { - static let accessibilityAPIMalfunctioning = Notification.Name("accessibilityAPIMalfunctioning") + static let accessibilityAPIMalfunctioning = Notification + .Name("XcodeInspector.accessibilityAPIMalfunctioning") + static let activeApplicationDidChange = Notification + .Name("XcodeInspector.activeApplicationDidChange") + static let previousActiveApplicationDidChange = Notification + .Name("XcodeInspector.previousActiveApplicationDidChange") + static let activeXcodeDidChange = Notification + .Name("XcodeInspector.activeXcodeDidChange") + static let latestActiveXcodeDidChange = Notification + .Name("XcodeInspector.latestActiveXcodeDidChange") + static let xcodesDidChange = Notification.Name("XcodeInspector.xcodesDidChange") + static let activeProjectRootURLDidChange = Notification + .Name("XcodeInspector.activeProjectRootURLDidChange") + static let activeDocumentURLDidChange = Notification + .Name("XcodeInspector.activeDocumentURLDidChange") + static let activeWorkspaceURLDidChange = Notification + .Name("XcodeInspector.activeWorkspaceURLDidChange") + static let focusedWindowDidChange = Notification + .Name("XcodeInspector.focusedWindowDidChange") + static let focusedEditorDidChange = Notification + .Name("XcodeInspector.focusedEditorDidChange") + static let focusedElementDidChange = Notification + .Name("XcodeInspector.focusedElementDidChange") + static let completionPanelDidChange = Notification + .Name("XcodeInspector.completionPanelDidChange") + static let xcodeWorkspacesDidChange = Notification + .Name("XcodeInspector.xcodeWorkspacesDidChange") } @globalActor @@ -18,55 +46,136 @@ public enum XcodeInspectorActor: GlobalActor { public static let shared = Actor() } -#warning("TODO: Consider rewriting it with Swift Observation") -public final class XcodeInspector: ObservableObject { - public static let shared = XcodeInspector() - - @XcodeInspectorActor - @dynamicMemberLookup - public class Safe { - var inspector: XcodeInspector { .shared } - nonisolated init() {} - public subscript(dynamicMember member: KeyPath) -> T { - inspector[keyPath: member] +@XcodeInspectorActor +@Perceptible +public final class XcodeInspector: Sendable { + public final class PerceptionObserver: Sendable { + public struct Cancellable { + let token: ObserveToken + public func cancel() { + token.cancel() + } + } + + final class Object: NSObject, Sendable {} + + let object = Object() + + @MainActor + @discardableResult public func observe( + _ block: @Sendable @escaping @MainActor () -> Void + ) -> Cancellable { + let token = object.observe { block() } + return Cancellable(token: token) } } + public nonisolated static func createObserver() -> PerceptionObserver { + PerceptionObserver() + } + + public nonisolated static let shared = XcodeInspector() + private var toast: ToastController { ToastControllerDependencyKey.liveValue } - private var cancellable = Set() - private var activeXcodeObservations = Set>() - private var appChangeObservations = Set>() - private var activeXcodeCancellable = Set() + @PerceptionIgnored private var activeXcodeObservations = Set>() + @PerceptionIgnored private var appChangeObservations = Set>() + + @MainActor + public fileprivate(set) var activeApplication: AppInstanceInspector? { + didSet { + NotificationCenter.default.post(name: .activeApplicationDidChange, object: nil) + } + } + + @MainActor + public fileprivate(set) var previousActiveApplication: AppInstanceInspector? { + didSet { + NotificationCenter.default.post(name: .previousActiveApplicationDidChange, object: nil) + } + } + + @MainActor + public fileprivate(set) var activeXcode: XcodeAppInstanceInspector? { + didSet { + NotificationCenter.default.post(name: .activeXcodeDidChange, object: nil) + NotificationCenter.default.post(name: .focusedWindowDidChange, object: nil) + NotificationCenter.default.post(name: .activeDocumentURLDidChange, object: self) + NotificationCenter.default.post(name: .activeWorkspaceURLDidChange, object: self) + NotificationCenter.default.post(name: .activeProjectRootURLDidChange, object: self) + NotificationCenter.default.post(name: .completionPanelDidChange, object: self) + NotificationCenter.default.post(name: .xcodeWorkspacesDidChange, object: self) + } + } + + @MainActor + public fileprivate(set) var latestActiveXcode: XcodeAppInstanceInspector? { + didSet { + _nonIsolatedLatestActiveXcode = latestActiveXcode + NotificationCenter.default.post(name: .latestActiveXcodeDidChange, object: nil) + } + } + + @MainActor + public fileprivate(set) var xcodes: [XcodeAppInstanceInspector] = [] { + didSet { + NotificationCenter.default.post(name: .xcodesDidChange, object: nil) + } + } + + @MainActor + public var activeProjectRootURL: URL? { + (activeXcode ?? latestActiveXcode)?.projectRootURL + } + + @MainActor + public var activeDocumentURL: URL? { + (activeXcode ?? latestActiveXcode)?.documentURL + } + + @MainActor + public var activeWorkspaceURL: URL? { + (activeXcode ?? latestActiveXcode)?.workspaceURL + } + + @MainActor + public var focusedWindow: XcodeWindowInspector? { + (activeXcode ?? latestActiveXcode)?.focusedWindow + } + + @MainActor + public var completionPanel: AXUIElement? { + (activeXcode ?? latestActiveXcode)?.completionPanel + } + + @MainActor + public fileprivate(set) var focusedEditor: SourceEditor? { + didSet { + NotificationCenter.default.post(name: .focusedEditorDidChange, object: nil) + } + } - #warning("TODO: Find a good way to make XcodeInspector thread safe!") - public var safe = Safe() - - @Published public fileprivate(set) var activeApplication: AppInstanceInspector? - @Published public fileprivate(set) var previousActiveApplication: AppInstanceInspector? - @Published public fileprivate(set) var activeXcode: XcodeAppInstanceInspector? - @Published public fileprivate(set) var latestActiveXcode: XcodeAppInstanceInspector? - @Published public fileprivate(set) var xcodes: [XcodeAppInstanceInspector] = [] - @Published public fileprivate(set) var activeProjectRootURL: URL? = nil - @Published public fileprivate(set) var activeDocumentURL: URL? = nil - @Published public fileprivate(set) var activeWorkspaceURL: URL? = nil - @Published public fileprivate(set) var focusedWindow: XcodeWindowInspector? - @Published public fileprivate(set) var focusedEditor: SourceEditor? - @Published public fileprivate(set) var focusedElement: AXUIElement? - @Published public fileprivate(set) var completionPanel: AXUIElement? + @MainActor + public fileprivate(set) var latestFocusedEditor: SourceEditor? + + @MainActor + public fileprivate(set) var focusedElement: AXUIElement? { + didSet { + NotificationCenter.default.post(name: .focusedElementDidChange, object: nil) + } + } /// Get the content of the source editor. /// /// - note: This method is expensive. It needs to convert index based ranges to line based /// ranges. - @XcodeInspectorActor public func getFocusedEditorContent() async -> EditorInformation? { guard let documentURL = realtimeActiveDocumentURL, let workspaceURL = realtimeActiveWorkspaceURL, - let projectURL = activeProjectRootURL + let projectURL = realtimeActiveProjectURL else { return nil } - let editorContent = focusedEditor?.getContent() + let editorContent = await latestFocusedEditor?.getContent() let language = languageIdentifierFromFileURL(documentURL) let relativePath = documentURL.path.replacingOccurrences(of: projectURL.path, with: "") @@ -99,61 +208,61 @@ public final class XcodeInspector: ObservableObject { ) } - public var realtimeActiveDocumentURL: URL? { - latestActiveXcode?.realtimeDocumentURL ?? activeDocumentURL + @PerceptionIgnored + private nonisolated(unsafe) var _nonIsolatedLatestActiveXcode: XcodeAppInstanceInspector? + + public nonisolated var realtimeActiveDocumentURL: URL? { + _nonIsolatedLatestActiveXcode?.realtimeDocumentURL } - public var realtimeActiveWorkspaceURL: URL? { - latestActiveXcode?.realtimeWorkspaceURL ?? activeWorkspaceURL + public nonisolated var realtimeActiveWorkspaceURL: URL? { + _nonIsolatedLatestActiveXcode?.realtimeWorkspaceURL } - public var realtimeActiveProjectURL: URL? { - latestActiveXcode?.realtimeProjectURL ?? activeProjectRootURL + public nonisolated var realtimeActiveProjectURL: URL? { + _nonIsolatedLatestActiveXcode?.realtimeProjectURL } - init() { + nonisolated init() { AXUIElement.setGlobalMessagingTimeout(3) - Task { @XcodeInspectorActor in - restart() - } + Task { await restart() } } - @XcodeInspectorActor - public func restart(cleanUp: Bool = false) { + public func restart(cleanUp: Bool = false) async { if cleanUp { activeXcodeObservations.forEach { $0.cancel() } activeXcodeObservations.removeAll() - activeXcodeCancellable.forEach { $0.cancel() } - activeXcodeCancellable.removeAll() - activeXcode = nil - latestActiveXcode = nil - activeApplication = nil - activeProjectRootURL = nil - activeDocumentURL = nil - activeWorkspaceURL = nil - focusedWindow = nil - focusedEditor = nil - focusedElement = nil - completionPanel = nil + await MainActor.run { + self.activeXcode = nil + latestActiveXcode = nil + activeApplication = nil + focusedEditor = nil + latestFocusedEditor = nil + focusedElement = nil + } } let runningApplications = NSWorkspace.shared.runningApplications - xcodes = runningApplications - .filter { $0.isXcode } - .map(XcodeAppInstanceInspector.init(runningApplication:)) - let activeXcode = xcodes.first(where: \.isActive) - latestActiveXcode = activeXcode ?? xcodes.first - activeApplication = activeXcode ?? runningApplications - .first(where: \.isActive) - .map(AppInstanceInspector.init(runningApplication:)) + + await MainActor.run { + xcodes = runningApplications + .filter { $0.isXcode } + .map(XcodeAppInstanceInspector.init(runningApplication:)) + let activeXcode = xcodes.first(where: \.isActive) + latestActiveXcode = activeXcode ?? xcodes.first + activeApplication = activeXcode ?? runningApplications + .first(where: \.isActive) + .map(AppInstanceInspector.init(runningApplication:)) + self.activeXcode = activeXcode + } appChangeObservations.forEach { $0.cancel() } appChangeObservations.removeAll() let appChangeTask = Task(priority: .utility) { [weak self] in guard let self else { return } - if let activeXcode { - setActiveXcode(activeXcode) + if let activeXcode = await self.activeXcode { + await setActiveXcode(activeXcode) } await withThrowingTaskGroup(of: Void.self) { [weak self] group in @@ -167,22 +276,22 @@ public final class XcodeInspector: ObservableObject { .userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { continue } if app.isXcode { - if let existed = xcodes.first(where: { + if let existed = await self.xcodes.first(where: { $0.processIdentifier == app.processIdentifier && !$0.isTerminated }) { - Task { @XcodeInspectorActor in - self.setActiveXcode(existed) + Task { + await self.setActiveXcode(existed) } } else { let new = XcodeAppInstanceInspector(runningApplication: app) - Task { @XcodeInspectorActor in + Task { @MainActor in self.xcodes.append(new) - self.setActiveXcode(new) + await self.setActiveXcode(new) } } } else { let appInspector = AppInstanceInspector(runningApplication: app) - Task { @XcodeInspectorActor in + Task { @MainActor in self.previousActiveApplication = self.activeApplication self.activeApplication = appInspector } @@ -201,7 +310,7 @@ public final class XcodeInspector: ObservableObject { else { continue } if app.isXcode { let processIdentifier = app.processIdentifier - Task { @XcodeInspectorActor in + Task { @MainActor in self.xcodes.removeAll { $0.processIdentifier == processIdentifier || $0.isTerminated } @@ -212,7 +321,7 @@ public final class XcodeInspector: ObservableObject { } if let activeXcode = self.xcodes.first(where: \.isActive) { - self.setActiveXcode(activeXcode) + await self.setActiveXcode(activeXcode) } } } @@ -232,8 +341,8 @@ public final class XcodeInspector: ObservableObject { } try await Task.sleep(nanoseconds: 10_000_000_000) - Task { @XcodeInspectorActor in - self.checkForAccessibilityMalfunction("Timer") + Task { + await self.checkForAccessibilityMalfunction("Timer") } } } @@ -255,60 +364,56 @@ public final class XcodeInspector: ObservableObject { appChangeObservations.insert(appChangeTask) } - public func reactivateObservationsToXcode() { - Task { @XcodeInspectorActor in - if let activeXcode { - setActiveXcode(activeXcode) - activeXcode.observeAXNotifications() - } + public func reactivateObservationsToXcode() async { + if let activeXcode = await activeXcode { + await setActiveXcode(activeXcode) + activeXcode.observeAXNotifications() } } - @XcodeInspectorActor - private func setActiveXcode(_ xcode: XcodeAppInstanceInspector) { - previousActiveApplication = activeApplication - activeApplication = xcode + private func setActiveXcode(_ xcode: XcodeAppInstanceInspector) async { + await MainActor.run { + previousActiveApplication = activeApplication + activeApplication = xcode + } xcode.refresh() for task in activeXcodeObservations { task.cancel() } - for cancellable in activeXcodeCancellable { cancellable.cancel() } activeXcodeObservations.removeAll() - activeXcodeCancellable.removeAll() - - activeXcode = xcode - latestActiveXcode = xcode - activeDocumentURL = xcode.documentURL - focusedWindow = xcode.focusedWindow - completionPanel = xcode.completionPanel - activeProjectRootURL = xcode.projectRootURL - activeWorkspaceURL = xcode.workspaceURL - focusedWindow = xcode.focusedWindow - - let setFocusedElement = { @XcodeInspectorActor [weak self] in + await MainActor.run { + activeXcode = xcode + latestActiveXcode = xcode + } + + let setFocusedElement = { [weak self] in guard let self else { return } - focusedElement = xcode.appElement.focusedElement - if let editorElement = focusedElement, editorElement.isSourceEditor { - focusedEditor = .init( - runningApplication: xcode.runningApplication, - element: editorElement - ) - } else if let element = focusedElement, - let editorElement = element.firstParent(where: \.isSourceEditor) - { - focusedEditor = .init( - runningApplication: xcode.runningApplication, - element: editorElement - ) - } else { - focusedEditor = nil + await MainActor.run { + self.focusedElement = xcode.appElement.focusedElement + if let editorElement = self.focusedElement, editorElement.isSourceEditor { + self.focusedEditor = .init( + runningApplication: xcode.runningApplication, + element: editorElement + ) + self.latestFocusedEditor = self.focusedEditor + } else if let element = self.focusedElement, + let editorElement = element.firstParent(where: \.isSourceEditor) + { + self.focusedEditor = .init( + runningApplication: xcode.runningApplication, + element: editorElement + ) + self.latestFocusedEditor = self.focusedEditor + } else { + self.focusedEditor = nil + } } } - setFocusedElement() - let focusedElementChanged = Task { @XcodeInspectorActor in + await setFocusedElement() + let focusedElementChanged = Task { for await notification in await xcode.axNotifications.notifications() { if notification.kind == .focusedUIElementChanged { try Task.checkCancellation() - setFocusedElement() + await setFocusedElement() } } } @@ -318,7 +423,7 @@ public final class XcodeInspector: ObservableObject { if UserDefaults.shared .value(for: \.restartXcodeInspectorIfAccessibilityAPIIsMalfunctioning) { - let malfunctionCheck = Task { @XcodeInspectorActor [weak self] in + let malfunctionCheck = Task { [weak self] in if #available(macOS 13.0, *) { let notifications = await xcode.axNotifications.notifications().filter { $0.kind == .uiElementDestroyed @@ -326,50 +431,30 @@ public final class XcodeInspector: ObservableObject { for await _ in notifications { guard let self else { return } try Task.checkCancellation() - self.checkForAccessibilityMalfunction("Element Destroyed") + await self.checkForAccessibilityMalfunction("Element Destroyed") } } } activeXcodeObservations.insert(malfunctionCheck) - checkForAccessibilityMalfunction("Reactivate Xcode") + await checkForAccessibilityMalfunction("Reactivate Xcode") } - - xcode.$completionPanel.sink { [weak self] element in - Task { @XcodeInspectorActor in self?.completionPanel = element } - }.store(in: &activeXcodeCancellable) - - xcode.$documentURL.sink { [weak self] url in - Task { @XcodeInspectorActor in self?.activeDocumentURL = url } - }.store(in: &activeXcodeCancellable) - - xcode.$workspaceURL.sink { [weak self] url in - Task { @XcodeInspectorActor in self?.activeWorkspaceURL = url } - }.store(in: &activeXcodeCancellable) - - xcode.$projectRootURL.sink { [weak self] url in - Task { @XcodeInspectorActor in self?.activeProjectRootURL = url } - }.store(in: &activeXcodeCancellable) - - xcode.$focusedWindow.sink { [weak self] window in - Task { @XcodeInspectorActor in self?.focusedWindow = window } - }.store(in: &activeXcodeCancellable) } private var lastRecoveryFromAccessibilityMalfunctioningTimeStamp = Date() - @XcodeInspectorActor - private func checkForAccessibilityMalfunction(_ source: String) { + private func checkForAccessibilityMalfunction(_ source: String) async { guard Date().timeIntervalSince(lastRecoveryFromAccessibilityMalfunctioningTimeStamp) > 5 else { return } - if let editor = focusedEditor, !editor.element.isSourceEditor { + if let editor = await focusedEditor, !editor.element.isSourceEditor { NotificationCenter.default.post( name: .accessibilityAPIMalfunctioning, object: "Source Editor Element Corrupted: \(source)" ) - } else if let element = activeXcode?.appElement.focusedElement { + } else if let element = await activeXcode?.appElement.focusedElement { + let focusedElement = await focusedElement if element.description != focusedElement?.description || element.role != focusedElement?.role { @@ -381,8 +466,7 @@ public final class XcodeInspector: ObservableObject { } } - @XcodeInspectorActor - private func recoverFromAccessibilityMalfunctioning(_ source: String?) { + private func recoverFromAccessibilityMalfunctioning(_ source: String?) async { let message = """ Accessibility API malfunction detected: \ \(source ?? ""). @@ -394,9 +478,9 @@ public final class XcodeInspector: ObservableObject { } else { Logger.service.info(message) } - if let activeXcode { + if let activeXcode = await activeXcode { lastRecoveryFromAccessibilityMalfunctioningTimeStamp = Date() - setActiveXcode(activeXcode) + await setActiveXcode(activeXcode) activeXcode.observeAXNotifications() } } diff --git a/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift b/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift index d2506822..c63b3f71 100644 --- a/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift @@ -2,45 +2,75 @@ import AppKit import AsyncPassthroughSubject import AXExtension import Combine +import CoreGraphics import Foundation import Logger +import Perception -public class XcodeWindowInspector: ObservableObject { - public let uiElement: AXUIElement +public class XcodeWindowInspector { + 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 + } + + var _uiElement: AXUIElement - init(uiElement: AXUIElement) { - self.uiElement = uiElement + init( + app: NSRunningApplication, + uiElement: AXUIElement + ) { + self.app = app + _uiElement = uiElement uiElement.setMessagingTimeout(2) + windowID = uiElement.windowID ?? 0 + } + + public var isInvalid: Bool { + uiElement.parent == nil } } -public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector { - let app: NSRunningApplication - @Published public internal(set) var documentURL: URL = .init(fileURLWithPath: "/") - @Published public internal(set) var workspaceURL: URL = .init(fileURLWithPath: "/") - @Published public internal(set) var projectRootURL: URL = .init(fileURLWithPath: "/") - private var focusedElementChangedTask: Task? +@XcodeInspectorActor +@Perceptible +public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector, Sendable { + @MainActor + public private(set) var documentURL: URL = .init(fileURLWithPath: "/") + @MainActor + public private(set) var workspaceURL: URL = .init(fileURLWithPath: "/") + @MainActor + public private(set) var projectRootURL: URL = .init(fileURLWithPath: "/") + @PerceptionIgnored private var focusedElementChangedTask: Task? public func refresh() { - Task { @XcodeInspectorActor in updateURLs() } + Task { @MainActor in updateURLs() } } + @MainActor public init( app: NSRunningApplication, uiElement: AXUIElement, axNotifications: AsyncPassthroughSubject ) { - self.app = app - super.init(uiElement: uiElement) + super.init(app: app, uiElement: uiElement) - focusedElementChangedTask = Task { [weak self, axNotifications] in - await self?.updateURLs() + focusedElementChangedTask = Task { @MainActor [weak self, axNotifications] in + self?.updateURLs() await withThrowingTaskGroup(of: Void.self) { [weak self] group in group.addTask { [weak self] in // prevent that documentURL may not be available yet try await Task.sleep(nanoseconds: 500_000_000) - if self?.documentURL == .init(fileURLWithPath: "/") { + if await self?.documentURL == .init(fileURLWithPath: "/") { await self?.updateURLs() } } @@ -60,32 +90,26 @@ public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector { } } - @XcodeInspectorActor + @MainActor func updateURLs() { let documentURL = Self.extractDocumentURL(windowElement: uiElement) if let documentURL { - Task { @MainActor in - self.documentURL = documentURL - } + self.documentURL = documentURL } let workspaceURL = Self.extractWorkspaceURL(windowElement: uiElement) if let workspaceURL { - Task { @MainActor in - self.workspaceURL = workspaceURL - } + self.workspaceURL = workspaceURL } let projectURL = Self.extractProjectURL( workspaceURL: workspaceURL, documentURL: documentURL ) if let projectURL { - Task { @MainActor in - self.projectRootURL = projectURL - } + projectRootURL = projectURL } } - static func extractDocumentURL( + nonisolated static func extractDocumentURL( windowElement: AXUIElement ) -> URL? { // fetch file path of the frontmost window of Xcode through Accessibility API. @@ -100,7 +124,7 @@ public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector { return nil } - static func extractWorkspaceURL( + nonisolated static func extractWorkspaceURL( windowElement: AXUIElement ) -> URL? { for child in windowElement.children { @@ -114,7 +138,7 @@ public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector { return nil } - public static func extractProjectURL( + public nonisolated static func extractProjectURL( workspaceURL: URL?, documentURL: URL? ) -> URL? { @@ -142,8 +166,8 @@ public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector { return lastGitDirectoryURL ?? firstDirectoryURL ?? workspaceURL } - - static func adjustFileURL(_ url: URL) -> URL { + + nonisolated static func adjustFileURL(_ url: URL) -> URL { if url.pathExtension == "playground", FileManager.default.fileIsDirectory(atPath: url.path) { diff --git a/Tool/Tests/JoinJSONTests/JoinJSONTests.swift b/Tool/Tests/JoinJSONTests/JoinJSONTests.swift new file mode 100644 index 00000000..05cbf3e6 --- /dev/null +++ b/Tool/Tests/JoinJSONTests/JoinJSONTests.swift @@ -0,0 +1,74 @@ +import Foundation + +import XCTest +@testable import JoinJSON + +final class JoinJSONTests: XCTestCase { + var sut: JoinJSON! + + override func setUp() { + super.setUp() + sut = JoinJSON() + } + + override func tearDown() { + sut = nil + super.tearDown() + } + + func test_join_two_valid_json_strings() throws { + let json1 = """ + {"name": "John"} + """ + let json2 = """ + {"age": 30} + """ + + let result = sut.join(json1, with: json2) + let dict = try JSONSerialization.jsonObject(with: result) as? [String: Any] + + XCTAssertEqual(dict?["name"] as? String, "John") + XCTAssertEqual(dict?["age"] as? Int, 30) + } + + func test_join_with_invalid_json_returns_first_data() { + let json1 = """ + {"name": "John"} + """ + let invalidJSON = "invalid json" + + let result = sut.join(json1, with: invalidJSON) + XCTAssertEqual(result, json1.data(using: .utf8)) + } + + func test_join_with_overlapping_keys_prefers_second_value() throws { + let json1 = """ + {"name": "John", "age": 25} + """ + let json2 = """ + {"age": 30} + """ + + let result = sut.join(json1, with: json2) + let dict = try JSONSerialization.jsonObject(with: result) as? [String: Any] + + XCTAssertEqual(dict?["name"] as? String, "John") + XCTAssertEqual(dict?["age"] as? Int, 30) + } + + func test_join_with_data_input() throws { + let data1 = """ + {"name": "John"} + """.data(using: .utf8)! + + let data2 = """ + {"age": 30} + """.data(using: .utf8)! + + let result = sut.join(data1, with: data2) + let dict = try JSONSerialization.jsonObject(with: result) as? [String: Any] + + XCTAssertEqual(dict?["name"] as? String, "John") + XCTAssertEqual(dict?["age"] as? Int, 30) + } +} 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..