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 aca1c01c..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,41 +23,11 @@ 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 = XcodeInspector.shared.realtimeActiveDocumentURL let projectURL = XcodeInspector.shared.realtimeActiveProjectURL @@ -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 a2e4ba8a..056e5761 100644 --- a/Copilot for Xcode.xcodeproj/project.pbxproj +++ b/Copilot for Xcode.xcodeproj/project.pbxproj @@ -235,6 +235,7 @@ C87B03AA293B262E00C77EAE /* PreviousSuggestionCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviousSuggestionCommand.swift; sourceTree = ""; }; C887BC832965D96000931567 /* DEVELOPMENT.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = DEVELOPMENT.md; sourceTree = ""; }; C89E75C22A46FB32000DD64F /* AppDelegate+Menu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+Menu.swift"; sourceTree = ""; }; + C8BE64922EB9B42E00EDB2D7 /* OverlayWindow */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = OverlayWindow; sourceTree = ""; }; C8CD828229B88006008D044D /* TestPlan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = TestPlan.xctestplan; sourceTree = ""; }; C8DCEFFF29CE11D500FDDDD7 /* OpenChat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenChat.swift; sourceTree = ""; }; C8DD9CB02BC673F80036641C /* CloseIdleTabsCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseIdleTabsCommand.swift; sourceTree = ""; }; @@ -342,6 +343,7 @@ C81458AE293A009800135263 /* Config.debug.xcconfig */, C8CD828229B88006008D044D /* TestPlan.xctestplan */, C828B27D2B1F241500E7612A /* ExtensionPoint.appextensionpoint */, + C8BE64922EB9B42E00EDB2D7 /* OverlayWindow */, C84FD9D72CC671C600BE5093 /* ChatPlugins */, C81D181E2A1B509B006C1B70 /* Tool */, C8189B282938979000C9DCDA /* Core */, @@ -784,7 +786,7 @@ "@executable_path/../Frameworks", "@executable_path/../../../../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = "$(APP_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).EditorExtension"; PRODUCT_NAME = Copilot; @@ -812,7 +814,7 @@ "@executable_path/../Frameworks", "@executable_path/../../../../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = "$(APP_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).EditorExtension"; PRODUCT_NAME = Copilot; @@ -965,7 +967,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = "$(APP_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE)"; PRODUCT_MODULE_NAME = Copilot_for_Xcode; @@ -999,7 +1001,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = "$(APP_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE)"; PRODUCT_NAME = "$(HOST_APP_NAME)"; @@ -1015,7 +1017,7 @@ DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5YKZ4Y3DAW; ENABLE_HARDENED_RUNTIME = YES; - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_VERSION = 5.0; @@ -1029,7 +1031,7 @@ DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5YKZ4Y3DAW; ENABLE_HARDENED_RUNTIME = YES; - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_VERSION = 5.0; @@ -1059,7 +1061,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = "$(APP_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).ExtensionService"; PRODUCT_NAME = "$(EXTENSION_SERVICE_NAME)"; @@ -1092,7 +1094,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = "$(APP_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).ExtensionService"; PRODUCT_NAME = "$(EXTENSION_SERVICE_NAME)"; @@ -1112,7 +1114,7 @@ ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).CommunicationBridge"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -1131,7 +1133,7 @@ ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).CommunicationBridge"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon.png deleted file mode 100644 index 291eaac7..00000000 Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon.png and /dev/null differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@128w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@128w.png deleted file mode 100644 index 160db273..00000000 Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@128w.png and /dev/null differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@16w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@16w.png deleted file mode 100644 index 4fcd6278..00000000 Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@16w.png and /dev/null differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w 1.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w 1.png deleted file mode 100644 index e31a8d3b..00000000 Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w 1.png and /dev/null differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w.png deleted file mode 100644 index e31a8d3b..00000000 Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w.png and /dev/null differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w 1.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w 1.png deleted file mode 100644 index ec264755..00000000 Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w 1.png and /dev/null differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w.png deleted file mode 100644 index ec264755..00000000 Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w.png and /dev/null differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w 1.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w 1.png deleted file mode 100644 index 4b760bc1..00000000 Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w 1.png and /dev/null differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w.png deleted file mode 100644 index 4b760bc1..00000000 Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w.png and /dev/null differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@64w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@64w.png deleted file mode 100644 index 8d777985..00000000 Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@64w.png and /dev/null differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/Contents.json b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/Contents.json index 56acb569..457c1fbf 100644 --- a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,61 +1,61 @@ { "images" : [ { - "filename" : "1024 x 1024 your icon@16w.png", + "filename" : "app-icon@16w.png", "idiom" : "mac", "scale" : "1x", "size" : "16x16" }, { - "filename" : "1024 x 1024 your icon@32w 1.png", + "filename" : "app-icon@32w.png", "idiom" : "mac", "scale" : "2x", "size" : "16x16" }, { - "filename" : "1024 x 1024 your icon@32w.png", + "filename" : "app-icon@32w.png", "idiom" : "mac", "scale" : "1x", "size" : "32x32" }, { - "filename" : "1024 x 1024 your icon@64w.png", + "filename" : "app-icon@64w.png", "idiom" : "mac", "scale" : "2x", "size" : "32x32" }, { - "filename" : "1024 x 1024 your icon@128w.png", + "filename" : "app-icon@128w.png", "idiom" : "mac", "scale" : "1x", "size" : "128x128" }, { - "filename" : "1024 x 1024 your icon@256w 1.png", + "filename" : "app-icon@256w.png", "idiom" : "mac", "scale" : "2x", "size" : "128x128" }, { - "filename" : "1024 x 1024 your icon@256w.png", + "filename" : "app-icon@256w.png", "idiom" : "mac", "scale" : "1x", "size" : "256x256" }, { - "filename" : "1024 x 1024 your icon@512w 1.png", + "filename" : "app-icon@512w.png", "idiom" : "mac", "scale" : "2x", "size" : "256x256" }, { - "filename" : "1024 x 1024 your icon@512w.png", + "filename" : "app-icon@512w.png", "idiom" : "mac", "scale" : "1x", "size" : "512x512" }, { - "filename" : "1024 x 1024 your icon.png", + "filename" : "app-icon.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon.png new file mode 100644 index 00000000..f7d77720 Binary files /dev/null and b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon.png differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@128w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@128w.png new file mode 100644 index 00000000..da0bb247 Binary files /dev/null and b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@128w.png differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@16w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@16w.png new file mode 100644 index 00000000..4f3fcc40 Binary files /dev/null and b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@16w.png differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@256w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@256w.png new file mode 100644 index 00000000..1f70976c Binary files /dev/null and b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@256w.png differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@32w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@32w.png new file mode 100644 index 00000000..44400214 Binary files /dev/null and b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@32w.png differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@512w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@512w.png new file mode 100644 index 00000000..78d81e50 Binary files /dev/null and b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@512w.png differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@64w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@64w.png new file mode 100644 index 00000000..a6aae457 Binary files /dev/null and b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@64w.png differ diff --git a/Core/Package.swift b/Core/Package.swift index fa46fdd0..6cd0910a 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -8,7 +8,7 @@ import PackageDescription let package = Package( name: "Core", - platforms: [.macOS(.v12)], + platforms: [.macOS(.v13)], products: [ .library( name: "Service", @@ -38,6 +38,7 @@ let package = Package( dependencies: [ .package(path: "../Tool"), .package(path: "../ChatPlugins"), + .package(path: "../OverlayWindow"), .package(url: "https://github.com/apple/swift-async-algorithms", from: "1.0.0"), .package(url: "https://github.com/gonzalezreal/swift-markdown-ui", from: "2.4.1"), .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.6.4"), @@ -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"), @@ -348,7 +350,7 @@ extension [Target.Dependency] { 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 3b9c1289..60a5504e 100644 --- a/Core/Sources/ChatContextCollectors/WebChatContextCollector/SearchFunction.swift +++ b/Core/Sources/ChatContextCollectors/WebChatContextCollector/SearchFunction.swift @@ -28,6 +28,10 @@ struct SearchFunction: ChatGPTFunction { """ }.joined(separator: "\n") } + + var userReadableContent: ChatGPTFunctionResultUserReadableContent { + .text(botReadableContent) + } } let maxTokens: Int diff --git a/Core/Sources/ChatGPTChatTab/Chat.swift b/Core/Sources/ChatGPTChatTab/Chat.swift index 2923d904..28443876 100644 --- a/Core/Sources/ChatGPTChatTab/Chat.swift +++ b/Core/Sources/ChatGPTChatTab/Chat.swift @@ -1,3 +1,4 @@ +import AppKit import ChatBasic import ChatService import ComposableArchitecture @@ -124,8 +125,6 @@ struct Chat { case sendMessage(UUID) } - @Dependency(\.openURL) var openURL - var body: some ReducerOf { BindingReducer() @@ -214,7 +213,7 @@ struct Chat { print(error) } } else if let url = URL(string: reference.uri), url.scheme != nil { - await openURL(url) + NSWorkspace.shared.open(url) } } diff --git a/Core/Sources/ChatGPTChatTab/Views/Instructions.swift b/Core/Sources/ChatGPTChatTab/Views/Instructions.swift index f15673b3..dba6bfbf 100644 --- a/Core/Sources/ChatGPTChatTab/Views/Instructions.swift +++ b/Core/Sources/ChatGPTChatTab/Views/Instructions.swift @@ -15,7 +15,7 @@ struct Instruction: View { | Plugin Name | Description | | --- | --- | - | `/run` | Runs a command under the project root | + | `/shell` | Runs a command under the project root | | `/shortcut(name)` | Runs a shortcut from the Shortcuts.app, with the previous message as input | To use plugins, you can prefix a message with `/pluginName`. diff --git a/Core/Sources/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 82e756ec..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 diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift index 7b4869fc..f0c673e5 100644 --- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift @@ -35,6 +35,7 @@ struct ChatModelEdit { var openAICompatibleSupportsMultipartMessageContent = true var requiresBeginWithUserMessage = false var customBody: String = "" + var supportsImages: Bool = true } enum Action: Equatable, BindableAction { @@ -290,7 +291,9 @@ extension ChatModel { 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 @@ -331,7 +334,8 @@ extension ChatModel { openAICompatibleSupportsMultipartMessageContent: info.openAICompatibleInfo .supportsMultipartMessageContent, requiresBeginWithUserMessage: info.openAICompatibleInfo.requiresBeginWithUserMessage, - customBody: info.customBodyInfo.jsonBody + 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 fd4256cd..d16b7556 100644 --- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift @@ -358,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( @@ -386,6 +390,10 @@ struct ChatModelEditView: View { MaxTokensTextField(store: store) SupportsFunctionCallingToggle(store: store) + + Toggle(isOn: $store.supportsImages) { + Text("Supports Images") + } } } } @@ -435,6 +443,10 @@ struct ChatModelEditView: View { Toggle(isOn: $store.requiresBeginWithUserMessage) { Text("Requires the first message to be from the user") } + + Toggle(isOn: $store.supportsImages) { + Text("Supports Images") + } } } } @@ -473,6 +485,10 @@ struct ChatModelEditView: View { MaxTokensTextField(store: store) TextField("API Version", text: $store.apiVersion, prompt: Text("v1")) + + Toggle(isOn: $store.supportsImages) { + Text("Supports Images") + } } } } @@ -496,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)." @@ -539,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( @@ -572,6 +596,10 @@ struct ChatModelEditView: View { 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( diff --git a/Core/Sources/HostApp/AccountSettings/GitHubCopilotView.swift b/Core/Sources/HostApp/AccountSettings/GitHubCopilotView.swift index d48d1486..ec627113 100644 --- a/Core/Sources/HostApp/AccountSettings/GitHubCopilotView.swift +++ b/Core/Sources/HostApp/AccountSettings/GitHubCopilotView.swift @@ -158,7 +158,7 @@ struct GitHubCopilotView: View { "node" ) ) { - Text("Path to Node (v20.8+)") + Text("Path to Node (v22.0+)") } Text( diff --git a/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift b/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift index 584d9101..033b9850 100644 --- a/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift +++ b/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift @@ -213,7 +213,7 @@ struct CustomCommandView: View { } SubSection(title: Text("Single Round Dialog")) { Text( - "This command allows you to send a message to a temporary chat without opening the chat panel. It is particularly useful for one-time commands, such as running a terminal command with `/run`. For example, you can set the prompt to `/run open .` to open the project in Finder." + "This command allows you to send a message to a temporary chat without opening the chat panel. It is particularly useful for one-time commands, such as running a terminal command with `/shell`. For example, you can set the prompt to `/shell open .` to open the project in Finder." ) } } diff --git a/Core/Sources/HostApp/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/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/KeyBindingManager/TabToAcceptSuggestion.swift b/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift index ce74605f..9c81038f 100644 --- a/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift +++ b/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift @@ -188,7 +188,9 @@ final class TabToAcceptSuggestion { } guard let presentingSuggestion = filespace.presentingSuggestion else { - Logger.service.info("TabToAcceptSuggestion: No Suggestions found") + Logger.service.info( + "TabToAcceptSuggestion: No presenting found for \(filespace.fileURL.lastPathComponent), found \(filespace.suggestions.count) suggestion, index \(filespace.suggestionIndex)." + ) return .unchanged } diff --git a/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift b/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift index 3dabb27a..25db646f 100644 --- a/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift +++ b/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift @@ -26,8 +26,8 @@ public final class SimpleModificationAgent: ModificationAgent { generateDescriptionRequirement: false ) - for try await (code, description) in stream { - continuation.yield(.code(code)) + for try await response in stream { + continuation.yield(response) } continuation.finish() @@ -51,7 +51,7 @@ public final class SimpleModificationAgent: ModificationAgent { isDetached: Bool, extraSystemPrompt: String?, generateDescriptionRequirement: Bool? - ) async throws -> AsyncThrowingStream<(code: String, description: String), Error> { + ) async throws -> AsyncThrowingStream { let userPreferredLanguage = UserDefaults.shared.value(for: \.chatGPTLanguage) let textLanguage = { if !UserDefaults.shared @@ -226,32 +226,38 @@ public final class SimpleModificationAgent: ModificationAgent { history.append(.init(role: .user, content: requirement)) } } - let stream = chatGPTService.send(memory).compactMap { response in - switch response { - case let .partialText(token): return token - default: return nil - } - }.eraseToThrowingStream() - + let stream = chatGPTService.send(memory) + return .init { continuation in - Task { - var content = "" - var extracted = extractCodeAndDescription(from: content) + let task = Task { + let parser = ExplanationThenCodeStreamParser() do { - for try await fragment in stream { - content.append(fragment) - extracted = extractCodeAndDescription(from: content) - if !content.isEmpty, extracted.code.isEmpty { - continuation.yield((code: content, description: "")) - } else { - continuation.yield(extracted) + func yield(fragments: [ExplanationThenCodeStreamParser.Fragment]) { + for fragment in fragments { + switch fragment { + case let .code(code): + continuation.yield(.code(code)) + case let .explanation(explanation): + continuation.yield(.explanation(explanation)) + } } } + + for try await response in stream { + guard case let .partialText(fragment) = response else { continue } + try Task.checkCancellation() + await yield(fragments: parser.yield(fragment)) + } + await yield(fragments: parser.finish()) continuation.finish() } catch { continuation.finish(throwing: error) } } + + continuation.onTermination = { _ in + task.cancel() + } } } } diff --git a/Core/Sources/Service/RealtimeSuggestionController.swift b/Core/Sources/Service/RealtimeSuggestionController.swift index 09661c0d..39770260 100644 --- a/Core/Sources/Service/RealtimeSuggestionController.swift +++ b/Core/Sources/Service/RealtimeSuggestionController.swift @@ -113,6 +113,7 @@ public actor RealtimeSuggestionController { Task { @WorkspaceActor in // Get cache ready for real-time suggestions. guard UserDefaults.shared.value(for: \.preCacheOnFileOpen) else { return } + guard await XcodeInspector.shared.activeApplication?.isXcode ?? false else { return } guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL else { return } let (_, filespace) = try await Service.shared.workspacePool diff --git a/Core/Sources/Service/Service.swift b/Core/Sources/Service/Service.swift index 1f5da7fc..b1702924 100644 --- a/Core/Sources/Service/Service.swift +++ b/Core/Sources/Service/Service.swift @@ -7,6 +7,7 @@ import Foundation import GitHubCopilotService import KeyBindingManager import Logger +import OverlayWindow import SuggestionService import Toast import Workspace @@ -37,6 +38,7 @@ public final class Service { let globalShortcutManager: GlobalShortcutManager let keyBindingManager: KeyBindingManager let xcodeThemeController: XcodeThemeController = .init() + let overlayWindowController: OverlayWindowController #if canImport(ProService) let proService: ProService @@ -54,6 +56,7 @@ public final class Service { realtimeSuggestionController = .init() scheduledCleaner = .init() + overlayWindowController = .init() #if canImport(ProService) proService = ProService() @@ -94,6 +97,7 @@ public final class Service { #if canImport(ProService) proService.start() #endif + overlayWindowController.start() DependencyUpdater().update() globalShortcutManager.start() keyBindingManager.start() diff --git a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift index d7f83696..c1d38d78 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift @@ -486,8 +486,7 @@ struct PseudoCommandHandler: CommandHandler { #endif } else { Task { - @Dependency(\.openURL) var openURL - await openURL(url) + NSWorkspace.shared.open(url) } } case let .builtinExtension(extensionIdentifier, id, _): @@ -688,7 +687,7 @@ extension PseudoCommandHandler { guard let filespace = await getFilespace(), let sourceEditor = await { if let sourceEditor { sourceEditor } - else { await XcodeInspector.shared.focusedEditor } + else { await XcodeInspector.shared.latestFocusedEditor } }() else { return nil } if Task.isCancelled { return nil } diff --git a/Core/Sources/SuggestionWidget/ChatPanelWindow.swift b/Core/Sources/SuggestionWidget/ChatPanelWindow.swift index cf9a4690..022b424c 100644 --- a/Core/Sources/SuggestionWidget/ChatPanelWindow.swift +++ b/Core/Sources/SuggestionWidget/ChatPanelWindow.swift @@ -51,10 +51,7 @@ final class ChatPanelWindow: WidgetWindow { }()) titlebarAppearsTransparent = true isReleasedWhenClosed = false - isOpaque = false - backgroundColor = .clear level = widgetLevel(1) - hasShadow = true contentView = NSHostingView( rootView: ChatWindowView( diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift index 2144100c..58c6f4d7 100644 --- a/Core/Sources/SuggestionWidget/ChatWindowView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift @@ -4,6 +4,7 @@ import ChatGPTChatTab import ChatTab import ComposableArchitecture import SwiftUI +import SharedUIComponents private let r: Double = 8 @@ -21,13 +22,14 @@ struct ChatWindowView: View { ChatTabBar(store: store) .frame(height: 26) + .clipped() Divider() ChatTabContainer(store: store) .frame(maxWidth: .infinity, maxHeight: .infinity) } - .xcodeStyleFrame(cornerRadius: 10) + .xcodeStyleFrame() .ignoresSafeArea(edges: .top) .onChange(of: store.isPanelDisplayed) { isDisplayed in toggleVisibility(isDisplayed) diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift index 44b53f96..cb68435f 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift @@ -139,9 +139,10 @@ public struct PromptToCodePanel { 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 @@ -172,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, @@ -315,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/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 3c0d8da0..e876728f 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanelView.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanelView.swift @@ -277,8 +277,13 @@ struct CodeBlockSuggestionPanelView: View { } .xcodeStyleFrame(cornerRadius: { switch suggestionPresentationMode { - case .nearbyTextCursor: 6 - case .floatingWidget: nil + case .nearbyTextCursor: + if #available(macOS 26.0, *) { + return 8 + } else { + return 6 + } + case .floatingWidget: return nil } }()) } diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift index d048f360..ef3b560c 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift @@ -288,12 +288,8 @@ extension PromptToCodePanelView { let isResponding = store.promptToCodeState.isGenerating let isCodeEmpty = store.promptToCodeState.snippets .allSatisfy(\.modifiedCode.isEmpty) - let isDescriptionEmpty = store.promptToCodeState.snippets - .allSatisfy(\.description.isEmpty) var isRespondingButCodeIsReady: Bool { - isResponding - && !isCodeEmpty - && !isDescriptionEmpty + isResponding && !isCodeEmpty } if !isResponding || isRespondingButCodeIsReady { HStack { @@ -619,20 +615,23 @@ extension PromptToCodePanelView { var body: some View { WithPerceptionTracking { - VStack(spacing: 0) { + VStack(alignment: .leading, spacing: 0) { SnippetTitleBar( store: store, language: language, codeForegroundColor: codeForegroundColor, isAttached: isAttached ) + + DescriptionContent(store: store, codeForegroundColor: codeForegroundColor) + CodeContent( store: store, language: language, isGenerating: isGenerating, codeForegroundColor: codeForegroundColor ) - DescriptionContent(store: store, codeForegroundColor: codeForegroundColor) + ErrorMessage(store: store) } } @@ -1146,7 +1145,12 @@ extension PromptToCodePanelView { ChatMessage.Reference( title: "Foo", content: "struct Foo { var foo: Int }", - kind: .symbol(.struct, uri: "file:///path/to/file.txt", startLine: 13, endLine: 13) + kind: .symbol( + .struct, + uri: "file:///path/to/file.txt", + startLine: 13, + endLine: 13 + ) ), ], )), diff --git a/Core/Sources/SuggestionWidget/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 b9999144..2f70e0e3 100644 --- a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift +++ b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift @@ -276,12 +276,18 @@ extension WidgetWindowsController { func generateWidgetLocation() async -> WidgetLocation? { if let application = await xcodeInspector.latestActiveXcode?.appElement { - if let focusElement = await xcodeInspector.focusedEditor?.element, + 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 windowContainingScreen = NSScreen.screens - .first(where: { $0.frame.contains(frame.origin) }) + let screen = NSScreen.screens.first( + where: { $0.frame.origin == .zero } + ) ?? NSScreen.main, + let windowContainingScreen = NSScreen.screens.first(where: { + let flippedScreenFrame = $0.frame.flipped(relativeTo: screen.frame) + return flippedScreenFrame.contains(frame.origin) + }) { let positionMode = UserDefaults.shared .value(for: \.suggestionWidgetPositionMode) @@ -291,6 +297,7 @@ extension WidgetWindowsController { switch positionMode { case .fixedToBottom: var result = UpdateLocationStrategy.FixedToBottom().framesForWindows( + windowFrame: windowFrame, editorFrame: frame, mainScreen: screen, activeScreen: windowContainingScreen @@ -300,7 +307,8 @@ extension WidgetWindowsController { result.suggestionPanelLocation = UpdateLocationStrategy .NearbyTextCursor() .framesForSuggestionWindow( - editorFrame: frame, mainScreen: screen, + editorFrame: frame, + mainScreen: screen, activeScreen: windowContainingScreen, editor: focusElement, completionPanel: await xcodeInspector.completionPanel @@ -311,6 +319,7 @@ extension WidgetWindowsController { return result case .alignToTextCursor: var result = UpdateLocationStrategy.AlignToTextCursor().framesForWindows( + windowFrame: windowFrame, editorFrame: frame, mainScreen: screen, activeScreen: windowContainingScreen, @@ -353,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 ) } } @@ -706,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 @@ -724,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( @@ -750,11 +746,10 @@ public final class WidgetWindows { defer: false ) it.isReleasedWhenClosed = false - it.isOpaque = false it.backgroundColor = .clear it.level = widgetLevel(2) it.hoveringLevel = widgetLevel(2) - it.hasShadow = true + it.hasShadow = false it.contentView = NSHostingView( rootView: SharedPanelView( store: store.scope( @@ -784,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( @@ -882,6 +878,8 @@ class WidgetWindow: CanBecomeKeyWindow { } var hoveringLevel: NSWindow.Level = widgetLevel(0) + + override var isFloatingPanel: Bool { true } var defaultCollectionBehavior: NSWindow.CollectionBehavior { [.fullScreenAuxiliary, .transient] @@ -936,3 +934,21 @@ func widgetLevel(_ addition: Int) -> NSWindow.Level { return .init(minimumWidgetLevel + addition) } +extension CGRect { + func flipped(relativeTo reference: CGRect) -> CGRect { + let flippedOrigin = CGPoint( + x: origin.x, + y: reference.height - origin.y - height + ) + return CGRect(origin: flippedOrigin, size: size) + } + + func relative(to reference: CGRect) -> CGRect { + let relativeOrigin = CGPoint( + x: origin.x - reference.origin.x, + y: origin.y - reference.origin.y + ) + return CGRect(origin: relativeOrigin, size: size) + } +} + diff --git a/EditorExtension/PromptToCodeCommand.swift b/EditorExtension/PromptToCodeCommand.swift index e7086d57..a2f814ac 100644 --- a/EditorExtension/PromptToCodeCommand.swift +++ b/EditorExtension/PromptToCodeCommand.swift @@ -4,7 +4,7 @@ import Foundation import XcodeKit class PromptToCodeCommand: NSObject, XCSourceEditorCommand, CommandType { - var name: String { "Write or Modify Code" } + var name: String { "Write or Edit Code" } func perform( with invocation: XCSourceEditorCommandInvocation, diff --git a/ExtensionService/AppDelegate+Menu.swift b/ExtensionService/AppDelegate+Menu.swift index 5bec033c..9107c97a 100644 --- a/ExtensionService/AppDelegate+Menu.swift +++ b/ExtensionService/AppDelegate+Menu.swift @@ -169,7 +169,7 @@ extension AppDelegate: NSMenuDelegate { menu.items.append(.text("Focused Element: N/A")) } - if let sourceEditor = inspector.focusedEditor { + if let sourceEditor = inspector.latestFocusedEditor { let label = sourceEditor.element.description menu.items .append(.text("Active Source Editor: \(label.isEmpty ? "Unknown" : label)")) @@ -226,6 +226,15 @@ extension AppDelegate: NSMenuDelegate { action: #selector(restartXcodeInspector), keyEquivalent: "" )) + + let isDebuggingOverlay = UserDefaults.shared.value(for: \.debugOverlayPanel) + let debugOverlayItem = NSMenuItem( + title: "Debug Window Overlays", + action: #selector(toggleDebugOverlayPanel), + keyEquivalent: "" + ) + debugOverlayItem.state = isDebuggingOverlay ? .on : .off + menu.items.append(debugOverlayItem) default: break @@ -266,6 +275,11 @@ private extension AppDelegate { await workspacePool.destroy() } } + + @objc func toggleDebugOverlayPanel() { + let isDebuggingOverlay = UserDefaults.shared.value(for: \.debugOverlayPanel) + UserDefaults.shared.set(!isDebuggingOverlay, for: \.debugOverlayPanel) + } } private extension NSMenuItem { diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon.png deleted file mode 100644 index 291eaac7..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@128w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@128w.png deleted file mode 100644 index 160db273..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@128w.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@16w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@16w.png deleted file mode 100644 index 4fcd6278..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@16w.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w 1.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w 1.png deleted file mode 100644 index e31a8d3b..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w 1.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w.png deleted file mode 100644 index e31a8d3b..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w 1.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w 1.png deleted file mode 100644 index ec264755..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w 1.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w.png deleted file mode 100644 index ec264755..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w 1.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w 1.png deleted file mode 100644 index 4b760bc1..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w 1.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w.png deleted file mode 100644 index 4b760bc1..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@64w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@64w.png deleted file mode 100644 index 8d777985..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@64w.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/Contents.json b/ExtensionService/Assets.xcassets/AppIcon.appiconset/Contents.json index 56acb569..d3a89dc6 100644 --- a/ExtensionService/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/ExtensionService/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,61 +1,61 @@ { "images" : [ { - "filename" : "1024 x 1024 your icon@16w.png", + "filename" : "service-icon@16w.png", "idiom" : "mac", "scale" : "1x", "size" : "16x16" }, { - "filename" : "1024 x 1024 your icon@32w 1.png", + "filename" : "service-icon@32w.png", "idiom" : "mac", "scale" : "2x", "size" : "16x16" }, { - "filename" : "1024 x 1024 your icon@32w.png", + "filename" : "service-icon@32w.png", "idiom" : "mac", "scale" : "1x", "size" : "32x32" }, { - "filename" : "1024 x 1024 your icon@64w.png", + "filename" : "service-icon@64w.png", "idiom" : "mac", "scale" : "2x", "size" : "32x32" }, { - "filename" : "1024 x 1024 your icon@128w.png", + "filename" : "service-icon@128w.png", "idiom" : "mac", "scale" : "1x", "size" : "128x128" }, { - "filename" : "1024 x 1024 your icon@256w 1.png", + "filename" : "service-icon@256w.png", "idiom" : "mac", "scale" : "2x", "size" : "128x128" }, { - "filename" : "1024 x 1024 your icon@256w.png", + "filename" : "service-icon@256w.png", "idiom" : "mac", "scale" : "1x", "size" : "256x256" }, { - "filename" : "1024 x 1024 your icon@512w 1.png", + "filename" : "service-icon@512w.png", "idiom" : "mac", "scale" : "2x", "size" : "256x256" }, { - "filename" : "1024 x 1024 your icon@512w.png", + "filename" : "service-icon@512w.png", "idiom" : "mac", "scale" : "1x", "size" : "512x512" }, { - "filename" : "1024 x 1024 your icon.png", + "filename" : "service-icon.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon.png new file mode 100644 index 00000000..29782a0f Binary files /dev/null and b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon.png differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@128w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@128w.png new file mode 100644 index 00000000..c9479d72 Binary files /dev/null and b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@128w.png differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@16w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@16w.png new file mode 100644 index 00000000..f00e273e Binary files /dev/null and b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@16w.png differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@256w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@256w.png new file mode 100644 index 00000000..0546b089 Binary files /dev/null and b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@256w.png differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@32w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@32w.png new file mode 100644 index 00000000..9f60ddf8 Binary files /dev/null and b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@32w.png differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@512w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@512w.png new file mode 100644 index 00000000..c00d18cf Binary files /dev/null and b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@512w.png differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@64w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@64w.png new file mode 100644 index 00000000..625b2717 Binary files /dev/null and b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@64w.png differ diff --git a/OverlayWindow/.gitignore b/OverlayWindow/.gitignore new file mode 100644 index 00000000..0023a534 --- /dev/null +++ b/OverlayWindow/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/OverlayWindow/Package.swift b/OverlayWindow/Package.swift new file mode 100644 index 00000000..b875c713 --- /dev/null +++ b/OverlayWindow/Package.swift @@ -0,0 +1,39 @@ +// swift-tools-version: 6.2 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "OverlayWindow", + platforms: [.macOS(.v13)], + products: [ + .library( + name: "OverlayWindow", + targets: ["OverlayWindow"] + ), + ], + dependencies: [ + .package(path: "../Tool"), + .package(url: "https://github.com/pointfreeco/swift-perception", from: "1.3.4"), + .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.4.0"), + ], + targets: [ + .target( + name: "OverlayWindow", + dependencies: [ + .product(name: "AppMonitoring", package: "Tool"), + .product(name: "Toast", package: "Tool"), + .product(name: "Preferences", package: "Tool"), + .product(name: "Logger", package: "Tool"), + .product(name: "DebounceFunction", package: "Tool"), + .product(name: "Perception", package: "swift-perception"), + .product(name: "Dependencies", package: "swift-dependencies"), + ] + ), + .testTarget( + name: "OverlayWindowTests", + dependencies: ["OverlayWindow", .product(name: "DebounceFunction", package: "Tool")] + ), + ] +) + diff --git a/OverlayWindow/Sources/OverlayWindow/IDEWorkspaceWindowOverlayWindowController.swift b/OverlayWindow/Sources/OverlayWindow/IDEWorkspaceWindowOverlayWindowController.swift new file mode 100644 index 00000000..48263e26 --- /dev/null +++ b/OverlayWindow/Sources/OverlayWindow/IDEWorkspaceWindowOverlayWindowController.swift @@ -0,0 +1,147 @@ +import AppKit +import AXExtension +import AXNotificationStream +import DebounceFunction +import Foundation +import Perception +import SwiftUI +import XcodeInspector + +@MainActor +public protocol IDEWorkspaceWindowOverlayWindowControllerContentProvider { + associatedtype Content: View + func createWindow() -> NSWindow? + func createContent() -> Content + func destroy() + + init(windowInspector: WorkspaceXcodeWindowInspector, application: NSRunningApplication) +} + +extension IDEWorkspaceWindowOverlayWindowControllerContentProvider { + var contentBody: AnyView { + AnyView(createContent()) + } +} + +@MainActor +final class IDEWorkspaceWindowOverlayWindowController { + private var lastAccessDate: Date = .init() + let application: NSRunningApplication + let inspector: WorkspaceXcodeWindowInspector + let contentProviders: [any IDEWorkspaceWindowOverlayWindowControllerContentProvider] + let maskPanel: OverlayPanel + var windowElement: AXUIElement + private var axNotificationTask: Task? + let updateFrameThrottler = ThrottleRunner(duration: 0.2) + + init( + inspector: WorkspaceXcodeWindowInspector, + application: NSRunningApplication, + contentProviderFactory: ( + _ windowInspector: WorkspaceXcodeWindowInspector, _ application: NSRunningApplication + ) -> [any IDEWorkspaceWindowOverlayWindowControllerContentProvider] + ) { + self.inspector = inspector + self.application = application + let contentProviders = contentProviderFactory(inspector, application) + self.contentProviders = contentProviders + windowElement = inspector.uiElement + + let panel = OverlayPanel( + contentRect: .init(x: 0, y: 0, width: 200, height: 200) + ) { + ZStack { + ForEach(0..( + contentRect: NSRect, + @ViewBuilder content: @escaping () -> Content + ) { + super.init( + contentRect: contentRect, + styleMask: [ + .borderless, + .nonactivatingPanel, + .fullSizeContentView, + ], + backing: .buffered, + defer: false + ) + + isReleasedWhenClosed = false + menu = nil + isOpaque = true + backgroundColor = .clear + hasShadow = false + alphaValue = 1.0 + collectionBehavior = [.fullScreenAuxiliary] + isFloatingPanel = true + titleVisibility = .hidden + titlebarAppearsTransparent = true + animationBehavior = .utilityWindow + + standardWindowButton(.closeButton)?.isHidden = true + standardWindowButton(.miniaturizeButton)?.isHidden = true + standardWindowButton(.zoomButton)?.isHidden = true + + contentView = NSHostingView( + rootView: ContentWrapper(panelState: panelState) { content() } + ) + } + + override public var canBecomeKey: Bool { + return _canBecomeKey + } + + override public var canBecomeMain: Bool { + return false + } + + override public func setIsVisible(_ visible: Bool) { + _canBecomeKey = false + defer { _canBecomeKey = true } + super.setIsVisible(visible) + } + + public func moveToActiveSpace() { + collectionBehavior = [.fullScreenAuxiliary, .moveToActiveSpace] + Task { @MainActor in + try await Task.sleep(nanoseconds: 50_000_000) + self.collectionBehavior = [.fullScreenAuxiliary] + } + } + + func setTopLeftCoordinateFrame(_ frame: CGRect, display: Bool) { + let zeroScreen = NSScreen.screens.first { $0.frame.origin == .zero } + ?? NSScreen.primaryScreen ?? NSScreen.main + let panelFrame = Self.convertAXRectToNSPanelFrame( + axRect: frame, + forPrimaryScreen: zeroScreen + ) + panelState.windowFrame = frame + panelState.windowFrameNSCoordinate = panelFrame + setFrame(panelFrame, display: display) + } + + static func convertAXRectToNSPanelFrame( + axRect: CGRect, + forPrimaryScreen screen: NSScreen? + ) -> CGRect { + guard let screen = screen else { return .zero } + let screenFrame = screen.frame + let flippedY = screenFrame.origin.y + screenFrame.size + .height - (axRect.origin.y + axRect.size.height) + return CGRect( + x: axRect.origin.x, + y: flippedY, + width: axRect.size.width, + height: axRect.size.height + ) + } + + struct ContentWrapper: View { + let panelState: PanelState + @ViewBuilder let content: () -> Content + @AppStorage(\.debugOverlayPanel) var debugOverlayPanel + + var body: some View { + WithPerceptionTracking { + ZStack { + Rectangle().fill(.green.opacity(debugOverlayPanel ? 0.1 : 0)) + .allowsHitTesting(false) + content() + .environment(\.overlayFrame, panelState.windowFrame) + .environment(\.overlayDebug, debugOverlayPanel) + } + } + } + } +} + +func overlayLevel(_ addition: Int) -> NSWindow.Level { + let minimumWidgetLevel: Int + #if DEBUG + minimumWidgetLevel = NSWindow.Level.floating.rawValue + 1 + #else + minimumWidgetLevel = NSWindow.Level.floating.rawValue + #endif + return .init(minimumWidgetLevel + addition) +} + +public extension CGRect { + func flipped(relativeTo reference: CGRect) -> CGRect { + let flippedOrigin = CGPoint( + x: origin.x, + y: reference.height - origin.y - height + ) + return CGRect(origin: flippedOrigin, size: size) + } + + func relative(to reference: CGRect) -> CGRect { + let relativeOrigin = CGPoint( + x: origin.x - reference.origin.x, + y: origin.y - reference.origin.y + ) + return CGRect(origin: relativeOrigin, size: size) + } +} + +public extension NSScreen { + var isPrimary: Bool { + let id = deviceDescription[.init("NSScreenNumber")] as? CGDirectDisplayID + return id == CGMainDisplayID() + } + + static var primaryScreen: NSScreen? { + NSScreen.screens.first { + let id = $0.deviceDescription[.init("NSScreenNumber")] as? CGDirectDisplayID + return id == CGMainDisplayID() + } + } +} + diff --git a/OverlayWindow/Sources/OverlayWindow/OverlayWindowController.swift b/OverlayWindow/Sources/OverlayWindow/OverlayWindowController.swift new file mode 100644 index 00000000..f4dbaa73 --- /dev/null +++ b/OverlayWindow/Sources/OverlayWindow/OverlayWindowController.swift @@ -0,0 +1,206 @@ +import AppKit +import DebounceFunction +import Foundation +import Perception +import XcodeInspector + +@MainActor +public final class OverlayWindowController { + public typealias IDEWorkspaceWindowOverlayWindowControllerContentProviderFactory = + @MainActor @Sendable ( + _ windowInspector: WorkspaceXcodeWindowInspector, + _ application: NSRunningApplication + ) -> any IDEWorkspaceWindowOverlayWindowControllerContentProvider + + static var ideWindowOverlayWindowControllerContentProviderFactories: + [IDEWorkspaceWindowOverlayWindowControllerContentProviderFactory] = [] + + var ideWindowOverlayWindowControllers = + [ObjectIdentifier: IDEWorkspaceWindowOverlayWindowController]() + var updateWindowStateTask: Task? + + let windowUpdateThrottler = ThrottleRunner(duration: 0.2) + + lazy var fullscreenDetector = { + let it = NSWindow( + contentRect: .zero, + styleMask: .borderless, + backing: .buffered, + defer: false + ) + it.isReleasedWhenClosed = false + it.isOpaque = false + it.backgroundColor = .clear + it.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient] + it.hasShadow = false + it.setIsVisible(false) + return it + }() + + public init() {} + + public func start() { + observeEvents() + _ = fullscreenDetector + } + + public nonisolated static func registerIDEWorkspaceWindowOverlayWindowControllerContentProviderFactory( + _ factory: @escaping IDEWorkspaceWindowOverlayWindowControllerContentProviderFactory + ) { + Task { @MainActor in + ideWindowOverlayWindowControllerContentProviderFactories.append(factory) + } + } +} + +extension OverlayWindowController { + func observeEvents() { + observeWindowChange() + + updateWindowStateTask = Task { [weak self] in + if let self { await handleSpaceChange() } + + await withThrowingTaskGroup(of: Void.self) { [weak self] group in + // active space did change + _ = group.addTaskUnlessCancelled { [weak self] in + let sequence = NSWorkspace.shared.notificationCenter + .notifications(named: NSWorkspace.activeSpaceDidChangeNotification) + for await _ in sequence { + guard let self else { return } + try Task.checkCancellation() + await handleSpaceChange() + } + } + } + } + } +} + +private extension OverlayWindowController { + func observeWindowChange() { + if ideWindowOverlayWindowControllers.isEmpty { + if let app = XcodeInspector.shared.activeXcode, + let windowInspector = XcodeInspector.shared + .focusedWindow as? WorkspaceXcodeWindowInspector + { + createNewIDEOverlayWindowController( + inspector: windowInspector, + application: app.runningApplication + ) + } + } + + withPerceptionTracking { + _ = XcodeInspector.shared.focusedWindow + _ = XcodeInspector.shared.activeXcode + _ = XcodeInspector.shared.activeApplication + } onChange: { [weak self] in + guard let self else { return } + Task { @MainActor in + defer { self.observeWindowChange() } + await self.windowUpdateThrottler.throttle { [weak self] in + await self?.handleOverlayStatusChange() + } + } + } + } + + func createNewIDEOverlayWindowController( + inspector: WorkspaceXcodeWindowInspector, + application: NSRunningApplication + ) { + let id = ObjectIdentifier(inspector) + let newController = IDEWorkspaceWindowOverlayWindowController( + inspector: inspector, + application: application, + contentProviderFactory: { + windowInspector, application in + OverlayWindowController.ideWindowOverlayWindowControllerContentProviderFactories + .map { $0(windowInspector, application) } + } + ) + newController.access() + ideWindowOverlayWindowControllers[id] = newController + } + + func removeIDEOverlayWindowController(for id: ObjectIdentifier) { + if let controller = ideWindowOverlayWindowControllers[id] { + controller.destroy() + } + ideWindowOverlayWindowControllers[id] = nil + } + + func handleSpaceChange() async { + let windowInspector = XcodeInspector.shared.focusedWindow + guard let activeWindowController = { + if let windowInspector = windowInspector as? WorkspaceXcodeWindowInspector { + let id = ObjectIdentifier(windowInspector) + return ideWindowOverlayWindowControllers[id] + } else { + return nil + } + }() else { return } + + let activeXcode = XcodeInspector.shared.activeXcode + let xcode = activeXcode?.appElement + let isXcodeActive = xcode?.isFrontmost ?? false + if isXcodeActive { + activeWindowController.maskPanel.moveToActiveSpace() + } + + if fullscreenDetector.isOnActiveSpace, xcode?.focusedWindow != nil { + activeWindowController.maskPanel.orderFrontRegardless() + } + } + + func handleOverlayStatusChange() { + guard XcodeInspector.shared.activeApplication?.isXcode ?? false else { + var closedControllers: [ObjectIdentifier] = [] + for (id, controller) in ideWindowOverlayWindowControllers { + if controller.isWindowClosed { + controller.dim() + closedControllers.append(id) + } else { + controller.dim() + } + } + for id in closedControllers { + removeIDEOverlayWindowController(for: id) + } + return + } + + guard let app = XcodeInspector.shared.activeXcode else { + for (_, controller) in ideWindowOverlayWindowControllers { + controller.hide() + } + return + } + + let windowInspector = XcodeInspector.shared.focusedWindow + if let ideWindowInspector = windowInspector as? WorkspaceXcodeWindowInspector { + let objectID = ObjectIdentifier(ideWindowInspector) + // Workspace window is active + // Hide all controllers first + for (id, controller) in ideWindowOverlayWindowControllers { + if id != objectID { + controller.hide() + } + } + if let controller = ideWindowOverlayWindowControllers[objectID] { + controller.access() + } else { + createNewIDEOverlayWindowController( + inspector: ideWindowInspector, + application: app.runningApplication + ) + } + } else { + // Not a workspace window, dim all controllers + for (_, controller) in ideWindowOverlayWindowControllers { + controller.dim() + } + } + } +} + diff --git a/OverlayWindow/Tests/OverlayWindowTests/WindowTests.swift b/OverlayWindow/Tests/OverlayWindowTests/WindowTests.swift new file mode 100644 index 00000000..98b0a5bf --- /dev/null +++ b/OverlayWindow/Tests/OverlayWindowTests/WindowTests.swift @@ -0,0 +1,5 @@ +import Testing + +@Test func example() async throws { + // Write your test here and use APIs like `#expect(...)` to check expected conditions. +} diff --git a/README.md b/README.md index 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 f015c57e..c9ebe525 100644 --- a/TestPlan.xctestplan +++ b/TestPlan.xctestplan @@ -24,86 +24,86 @@ "testTargets" : [ { "target" : { - "containerPath" : "container:Core", - "identifier" : "ChatServiceTests", - "name" : "ChatServiceTests" + "containerPath" : "container:Tool", + "identifier" : "SharedUIComponentsTests", + "name" : "SharedUIComponentsTests" } }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "OpenAIServiceTests", - "name" : "OpenAIServiceTests" + "identifier" : "ActiveDocumentChatContextCollectorTests", + "name" : "ActiveDocumentChatContextCollectorTests" } }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "SuggestionInjectorTests", - "name" : "SuggestionInjectorTests" + "identifier" : "CodeDiffTests", + "name" : "CodeDiffTests" } }, { "target" : { - "containerPath" : "container:Tool", - "identifier" : "WebSearchServiceTests", - "name" : "WebSearchServiceTests" + "containerPath" : "container:Core", + "identifier" : "ServiceUpdateMigrationTests", + "name" : "ServiceUpdateMigrationTests" } }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "GitHubCopilotServiceTests", - "name" : "GitHubCopilotServiceTests" + "identifier" : "ASTParserTests", + "name" : "ASTParserTests" } }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "JoinJSONTests", - "name" : "JoinJSONTests" + "identifier" : "SuggestionProviderTests", + "name" : "SuggestionProviderTests" } }, { "target" : { - "containerPath" : "container:Tool", - "identifier" : "CodeDiffTests", - "name" : "CodeDiffTests" + "containerPath" : "container:Core", + "identifier" : "PromptToCodeServiceTests", + "name" : "PromptToCodeServiceTests" } }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "SuggestionBasicTests", - "name" : "SuggestionBasicTests" + "identifier" : "KeychainTests", + "name" : "KeychainTests" } }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "FocusedCodeFinderTests", - "name" : "FocusedCodeFinderTests" + "identifier" : "JoinJSONTests", + "name" : "JoinJSONTests" } }, { "target" : { - "containerPath" : "container:Tool", - "identifier" : "ASTParserTests", - "name" : "ASTParserTests" + "containerPath" : "container:Core", + "identifier" : "ServiceTests", + "name" : "ServiceTests" } }, { "target" : { - "containerPath" : "container:Core", - "identifier" : "PromptToCodeServiceTests", - "name" : "PromptToCodeServiceTests" + "containerPath" : "container:OverlayWindow", + "identifier" : "OverlayWindowTests", + "name" : "OverlayWindowTests" } }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "TokenEncoderTests", - "name" : "TokenEncoderTests" + "identifier" : "SuggestionBasicTests", + "name" : "SuggestionBasicTests" } }, { @@ -116,64 +116,71 @@ { "target" : { "containerPath" : "container:Tool", - "identifier" : "SharedUIComponentsTests", - "name" : "SharedUIComponentsTests" + "identifier" : "GitHubCopilotServiceTests", + "name" : "GitHubCopilotServiceTests" + } + }, + { + "target" : { + "containerPath" : "container:Tool", + "identifier" : "OpenAIServiceTests", + "name" : "OpenAIServiceTests" } }, { "target" : { "containerPath" : "container:Core", - "identifier" : "KeyBindingManagerTests", - "name" : "KeyBindingManagerTests" + "identifier" : "SuggestionWidgetTests", + "name" : "SuggestionWidgetTests" } }, { "target" : { - "containerPath" : "container:Tool", - "identifier" : "XcodeInspectorTests", - "name" : "XcodeInspectorTests" + "containerPath" : "container:Core", + "identifier" : "KeyBindingManagerTests", + "name" : "KeyBindingManagerTests" } }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "ActiveDocumentChatContextCollectorTests", - "name" : "ActiveDocumentChatContextCollectorTests" + "identifier" : "FocusedCodeFinderTests", + "name" : "FocusedCodeFinderTests" } }, { "target" : { - "containerPath" : "container:Core", - "identifier" : "ServiceUpdateMigrationTests", - "name" : "ServiceUpdateMigrationTests" + "containerPath" : "container:Tool", + "identifier" : "WebSearchServiceTests", + "name" : "WebSearchServiceTests" } }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "SuggestionProviderTests", - "name" : "SuggestionProviderTests" + "identifier" : "XcodeInspectorTests", + "name" : "XcodeInspectorTests" } }, { "target" : { "containerPath" : "container:Core", - "identifier" : "ServiceTests", - "name" : "ServiceTests" + "identifier" : "ChatServiceTests", + "name" : "ChatServiceTests" } }, { "target" : { - "containerPath" : "container:Core", - "identifier" : "SuggestionWidgetTests", - "name" : "SuggestionWidgetTests" + "containerPath" : "container:Tool", + "identifier" : "SuggestionInjectorTests", + "name" : "SuggestionInjectorTests" } }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "KeychainTests", - "name" : "KeychainTests" + "identifier" : "TokenEncoderTests", + "name" : "TokenEncoderTests" } } ], diff --git a/Tool/Package.swift b/Tool/Package.swift index 69e17995..f303e44c 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -223,6 +223,7 @@ let package = Package( name: "ModificationBasic", dependencies: [ "SuggestionBasic", + "ChatBasic", .product(name: "CodableWrappers", package: "CodableWrappers"), .product( name: "ComposableArchitecture", @@ -230,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" @@ -495,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/AXExtension/AXUIElement.swift b/Tool/Sources/AXExtension/AXUIElement.swift index 772a75af..e54bfaff 100644 --- a/Tool/Sources/AXExtension/AXUIElement.swift +++ b/Tool/Sources/AXExtension/AXUIElement.swift @@ -58,7 +58,9 @@ public extension AXUIElement { } var isSourceEditor: Bool { - description == "Source Editor" && roleDescription != "unknown" + if !(description == "Source Editor" && role != kAXUnknownRole) { return false } + if let _ = firstParent(where: { $0.identifier == "editor context" }) { return true } + return false } var selectedTextRange: ClosedRange? { @@ -82,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 @@ -136,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 @@ -225,7 +265,7 @@ public extension AXUIElement { fatalError("AXUIElement.children: Exceeding recommended depth.") } #endif - + var all = [AXUIElement]() for child in children { if match(child) { all.append(child) } @@ -242,11 +282,19 @@ public extension AXUIElement { return parent.firstParent(where: match) } - func firstChild(depth: Int = 0, where match: (AXUIElement) -> Bool) -> AXUIElement? { + func firstChild( + depth: Int = 0, + maxDepth: Int = 50, + where match: (AXUIElement) -> Bool + ) -> AXUIElement? { #if DEBUG - if depth >= 50 { + if depth > maxDepth { fatalError("AXUIElement.firstChild: Exceeding recommended depth.") } + #else + if depth > maxDepth { + return nil + } #endif for child in children { if match(child) { return child } @@ -273,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 } @@ -287,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 } } @@ -314,7 +377,37 @@ public extension AXUIElement { } } - _ = _traverse(element: self, level: 0, handle: handle) + _ = _traverse(element: self, level: 0, info: info, handle: handle) + + #if DEBUG +// let duration = Date().timeIntervalSince(startDate) +// .formatted(.number.precision(.fractionLength(0...4))) +// Logger.service.debug( +// "AXUIElement.traverse count: \(count), took \(duration) seconds", +// file: file, +// line: line, +// function: function +// ) + #endif + } + + /// Traversing the element tree. + /// + /// - important: Traversing the element tree is resource consuming and will affect the + /// **performance of Xcode**. Please make sure to skip as much as possible. + /// + /// - todo: Make it not recursive. + func traverse( + access: (AXUIElement) -> [AXUIElement] = { $0.children }, + file: StaticString = #file, + line: UInt = #line, + function: StaticString = #function, + _ handle: (_ element: AXUIElement, _ level: Int) -> SearchNextStep + ) { + traverse(access: access, info: (), file: file, line: line, function: function) { + element, level, _ in + handle(element, level) + } } } diff --git a/Tool/Sources/AXExtension/AXUIElementPrivateAPI.swift b/Tool/Sources/AXExtension/AXUIElementPrivateAPI.swift new file mode 100644 index 00000000..bd861a3f --- /dev/null +++ b/Tool/Sources/AXExtension/AXUIElementPrivateAPI.swift @@ -0,0 +1,8 @@ +import AppKit + +/// AXError _AXUIElementGetWindow(AXUIElementRef element, uint32_t *identifier); +@_silgen_name("_AXUIElementGetWindow") @discardableResult +func AXUIElementGetWindow( + _ element: AXUIElement, + _ identifier: UnsafeMutablePointer +) -> AXError diff --git a/Tool/Sources/AXNotificationStream/AXNotificationStream.swift b/Tool/Sources/AXNotificationStream/AXNotificationStream.swift index 89fca015..b361f8ae 100644 --- a/Tool/Sources/AXNotificationStream/AXNotificationStream.swift +++ b/Tool/Sources/AXNotificationStream/AXNotificationStream.swift @@ -133,9 +133,12 @@ public final class AXNotificationStream: AsyncSequence { .error("AXObserver: Accessibility API disabled, will try again later") retry -= 1 case .invalidUIElement: + // It's possible that the UI element is not ready yet. + // + // Especially when you retrieve an element right after macOS is + // awaken from sleep. Logger.service .error("AXObserver: Invalid UI element, notification name \(name)") - pendingRegistrationNames.remove(name) case .invalidUIElementObserver: Logger.service.error("AXObserver: Invalid UI element observer") pendingRegistrationNames.remove(name) diff --git a/Tool/Sources/ChatBasic/ChatAgent.swift b/Tool/Sources/ChatBasic/ChatAgent.swift index 763233b9..1b6b835d 100644 --- a/Tool/Sources/ChatBasic/ChatAgent.swift +++ b/Tool/Sources/ChatBasic/ChatAgent.swift @@ -33,17 +33,20 @@ public struct ChatAgentRequest { public var history: [ChatMessage] public var references: [ChatMessage.Reference] public var topics: [ChatMessage.Reference] + public var agentInstructions: String? = nil public init( text: String, history: [ChatMessage], references: [ChatMessage.Reference], - topics: [ChatMessage.Reference] + topics: [ChatMessage.Reference], + agentInstructions: String? = nil ) { self.text = text self.history = history self.references = references self.topics = topics + self.agentInstructions = agentInstructions } } diff --git a/Tool/Sources/ChatBasic/ChatGPTFunction.swift b/Tool/Sources/ChatBasic/ChatGPTFunction.swift index 77913a2a..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. diff --git a/Tool/Sources/ChatBasic/ChatMessage.swift b/Tool/Sources/ChatBasic/ChatMessage.swift index 033e3578..ab5f04a4 100644 --- a/Tool/Sources/ChatBasic/ChatMessage.swift +++ b/Tool/Sources/ChatBasic/ChatMessage.swift @@ -1,12 +1,12 @@ -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 @@ -15,7 +15,7 @@ public struct ChatMessage: Equatable, Codable { } /// 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. @@ -27,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 @@ -49,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. @@ -61,10 +61,10 @@ public struct ChatMessage: Equatable, Codable { } /// A reference to include in a chat message. - public struct Reference: Codable, Equatable, Identifiable { + 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` @@ -77,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. @@ -114,6 +115,25 @@ public struct ChatMessage: Equatable, Codable { } } + 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 @@ -138,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? @@ -151,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 @@ -174,6 +198,7 @@ public struct ChatMessage: Equatable, Codable { summary: String? = nil, tokenCount: Int? = nil, references: [Reference] = [], + images: [Image] = [], cacheIfPossible: Bool = false ) { self.role = role @@ -186,19 +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 ReferenceIDFallback: FallbackValueProvider { +public struct ReferenceIDFallback: FallbackValueProvider, Sendable { public static var defaultValue: String { UUID().uuidString } } -public struct ChatMessageRoleFallback: FallbackValueProvider { +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/LanguageServer/CodeiumInstallationManager.swift b/Tool/Sources/CodeiumService/LanguageServer/CodeiumInstallationManager.swift index 295a4467..29936bf8 100644 --- a/Tool/Sources/CodeiumService/LanguageServer/CodeiumInstallationManager.swift +++ b/Tool/Sources/CodeiumService/LanguageServer/CodeiumInstallationManager.swift @@ -3,7 +3,7 @@ import Terminal public struct CodeiumInstallationManager { private static var isInstalling = false - static let latestSupportedVersion = "1.46.3" + static let latestSupportedVersion = "1.48.2" static let minimumSupportedVersion = "1.20.0" public init() {} diff --git a/Tool/Sources/CustomAsyncAlgorithms/TimedDebounce.swift b/Tool/Sources/CustomAsyncAlgorithms/TimedDebounce.swift index df296cc8..bb97d82f 100644 --- a/Tool/Sources/CustomAsyncAlgorithms/TimedDebounce.swift +++ b/Tool/Sources/CustomAsyncAlgorithms/TimedDebounce.swift @@ -46,18 +46,27 @@ public extension AsyncSequence { /// /// In the future when we drop macOS 12 support we should just use chunked from AsyncAlgorithms. func timedDebounce( - for duration: TimeInterval + for duration: TimeInterval, + reducer: @escaping @Sendable (Element, Element) -> Element ) -> AsyncThrowingStream { return AsyncThrowingStream { continuation in Task { - let function = TimedDebounceFunction(duration: duration) { value in - continuation.yield(value) - } + let storage = TimedDebounceStorage() + var lastTimeStamp = Date() do { for try await value in self { - await function(value) + await storage.reduce(value, reducer: reducer) + let now = Date() + if now.timeIntervalSince(lastTimeStamp) >= duration { + lastTimeStamp = now + if let value = await storage.consume() { + continuation.yield(value) + } + } + } + if let value = await storage.consume() { + continuation.yield(value) } - await function.finish() continuation.finish() } catch { continuation.finish(throwing: error) @@ -67,3 +76,19 @@ public extension AsyncSequence { } } +private actor TimedDebounceStorage { + var value: Element? + func reduce(_ value: Element, reducer: (Element, Element) -> Element) async { + if let existing = self.value { + self.value = reducer(existing, value) + } else { + self.value = value + } + } + + func consume() -> Element? { + defer { value = nil } + return value + } +} + diff --git a/Tool/Sources/CustomCommandTemplateProcessor/CustomCommandTemplateProcessor.swift b/Tool/Sources/CustomCommandTemplateProcessor/CustomCommandTemplateProcessor.swift index 9f648612..891c8301 100644 --- a/Tool/Sources/CustomCommandTemplateProcessor/CustomCommandTemplateProcessor.swift +++ b/Tool/Sources/CustomCommandTemplateProcessor/CustomCommandTemplateProcessor.swift @@ -39,7 +39,7 @@ public struct CustomCommandTemplateProcessor { } func getEditorInformation() async -> EditorInformation { - let editorContent = await XcodeInspector.shared.focusedEditor?.getContent() + let editorContent = await XcodeInspector.shared.latestFocusedEditor?.getContent() let documentURL = await XcodeInspector.shared.activeDocumentURL let language = documentURL.map(languageIdentifierFromFileURL) ?? .plaintext diff --git a/Tool/Sources/DebounceFunction/ThrottleFunction.swift b/Tool/Sources/DebounceFunction/ThrottleFunction.swift index 52a4f0ca..d0532397 100644 --- a/Tool/Sources/DebounceFunction/ThrottleFunction.swift +++ b/Tool/Sources/DebounceFunction/ThrottleFunction.swift @@ -8,7 +8,7 @@ public actor ThrottleFunction { var lastFinishTime: Date = .init(timeIntervalSince1970: 0) var now: () -> Date = { Date() } - public init(duration: TimeInterval, block: @escaping (T) async -> Void) { + public init(duration: TimeInterval, block: @escaping @Sendable (T) async -> Void) { self.duration = duration self.block = block } @@ -50,13 +50,13 @@ public actor ThrottleRunner { self.duration = duration } - public func throttle(block: @escaping () async -> Void) { + public func throttle(block: @escaping @Sendable () async -> Void) { if task == nil { scheduleTask(wait: now().timeIntervalSince(lastFinishTime) < duration, block: block) } } - func scheduleTask(wait: Bool, block: @escaping () async -> Void) { + func scheduleTask(wait: Bool, block: @escaping @Sendable () async -> Void) { task = Task.detached { [weak self] in guard let self else { return } do { diff --git a/Tool/Sources/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 8ff625f8..606499a4 100644 --- a/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift +++ b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift @@ -1,9 +1,11 @@ import BuiltinExtension import CopilotForXcodeKit +import Dependencies import Foundation import LanguageServerProtocol import Logger import Preferences +import Toast import Workspace public final class GitHubCopilotExtension: BuiltinExtension { @@ -20,6 +22,8 @@ public final class GitHubCopilotExtension: BuiltinExtension { extensionUsage.isSuggestionServiceInUse || extensionUsage.isChatServiceInUse } + @Dependency(\.toastController) var toast + let workspacePool: WorkspacePool let serviceLocator: ServiceLocatorType @@ -49,6 +53,16 @@ public final class GitHubCopilotExtension: BuiltinExtension { let content = try String(contentsOf: documentURL, encoding: .utf8) guard let service = await serviceLocator.getService(from: workspace) else { return } try await service.notifyOpenTextDocument(fileURL: documentURL, content: content) + } catch let error as ServerError { + let e = GitHubCopilotError.languageServerError(error) + Logger.gitHubCopilot.error(e.localizedDescription) + + switch error { + case .serverUnavailable, .serverError: + toast.toast(content: e.localizedDescription, type: .error, duration: 10.0) + default: + break + } } catch { Logger.gitHubCopilot.error(error.localizedDescription) } @@ -295,7 +309,7 @@ extension GitHubCopilotExtension { var id: String var capabilities: Capability } - + struct Body: Decodable { var data: [Model] } diff --git a/Tool/Sources/GitHubCopilotService/GitHubCopilotWorkspacePlugin.swift b/Tool/Sources/GitHubCopilotService/GitHubCopilotWorkspacePlugin.swift index e893c133..405a5402 100644 --- a/Tool/Sources/GitHubCopilotService/GitHubCopilotWorkspacePlugin.swift +++ b/Tool/Sources/GitHubCopilotService/GitHubCopilotWorkspacePlugin.swift @@ -1,8 +1,8 @@ +import Dependencies import Foundation import Logger -import Workspace import Toast -import Dependencies +import Workspace public final class GitHubCopilotWorkspacePlugin: WorkspacePlugin { enum Error: Swift.Error, LocalizedError { @@ -14,7 +14,7 @@ public final class GitHubCopilotWorkspacePlugin: WorkspacePlugin { } } } - + @Dependency(\.toast) var toast let installationManager = GitHubCopilotInstallationManager() @@ -32,6 +32,10 @@ public final class GitHubCopilotWorkspacePlugin: WorkspacePlugin { return nil } catch { Logger.gitHubCopilot.error("Failed to create GitHub Copilot service: \(error)") + toast( + "Failed to start GitHub Copilot language server: \(error.localizedDescription)", + .error + ) return nil } } @@ -74,7 +78,7 @@ public final class GitHubCopilotWorkspacePlugin: WorkspacePlugin { } } } - + @GitHubCopilotSuggestionActor func updateLanguageServerIfPossible() async { guard !GitHubCopilotInstallationManager.isInstalling else { return } diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotInstallationManager.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotInstallationManager.swift index 2971786e..edf59a50 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotInstallationManager.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotInstallationManager.swift @@ -6,7 +6,7 @@ public struct GitHubCopilotInstallationManager { public private(set) static var isInstalling = false static var downloadURL: URL { - let commitHash = "18f485d892b56b311fd752039d6977333ebc2a0f" + let commitHash = "f89e977c87180519ba3b942200e3d05b17b1e2fc" let link = "https://github.com/github/copilot.vim/archive/\(commitHash).zip" return URL(string: link)! } @@ -14,7 +14,7 @@ public struct GitHubCopilotInstallationManager { /// The GitHub's version has quite a lot of changes about `watchedFiles` since the following /// commit. /// https://github.com/github/CopilotForXcode/commit/a50045aa3ab3b7d532cadf40c4c10bed32f81169#diff-678798cf677bcd1ce276809cfccd33da9ff594b1b0c557180210a4ed2bd27ffa - static let latestSupportedVersion = "1.48.0" + static let latestSupportedVersion = "1.57.0" static let minimumSupportedVersion = "1.32.0" public init() {} @@ -151,7 +151,15 @@ public struct GitHubCopilotInstallationManager { return } - let lspURL = gitFolderURL.appendingPathComponent("dist") + let lspURL = { + let caseA = gitFolderURL.appendingPathComponent("dist") + if FileManager.default.fileExists(atPath: caseA.path) { + return caseA + } + return gitFolderURL + .appendingPathComponent("copilot-language-server") + .appendingPathComponent("dist") + }() let copilotURL = urls.executableURL.appendingPathComponent("copilot") if !FileManager.default.fileExists(atPath: copilotURL.path) { diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift index 2f874200..486ddd92 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift @@ -83,7 +83,7 @@ enum GitHubCopilotError: Error, LocalizedError { case let .clientDataUnavailable(error): return "Language server error: Client data unavailable: \(error)" case .serverUnavailable: - return "Language server error: Server unavailable, please make sure that:\n1. The path to node is correctly set.\n2. The node is not a shim executable.\n3. the node version is high enough." + return "Language server error: Server unavailable, please make sure that:\n1. The path to node is correctly set.\n2. The node is not a shim executable.\n3. the node version is high enough (v22.0+)." case .missingExpectedParameter: return "Language server error: Missing expected parameter" case .missingExpectedResult: @@ -523,11 +523,23 @@ public final class GitHubCopilotService: GitHubCopilotBaseService, // And sometimes the language server's content was not up to date and may generate // weird result when the cursor position exceeds the line. let task = Task { @GitHubCopilotSuggestionActor in - try await notifyChangeTextDocument( - fileURL: fileURL, - content: content, - version: 1 - ) + do { + try await notifyChangeTextDocument( + fileURL: fileURL, + content: content, + version: 1 + ) + } catch let error as ServerError { + switch error { + case .serverUnavailable: + throw SuggestionServiceError + .notice(GitHubCopilotError.languageServerError(error)) + default: + throw error + } + } catch { + throw error + } do { try Task.checkCancellation() diff --git a/Tool/Sources/Logger/Logger.swift b/Tool/Sources/Logger/Logger.swift index 2d791376..58d280f0 100644 --- a/Tool/Sources/Logger/Logger.swift +++ b/Tool/Sources/Logger/Logger.swift @@ -53,7 +53,11 @@ public final class Logger { osLogType = .error } + #if DEBUG + os_log("%{public}@", log: osLog, type: osLogType, "\(file):\(line) \(function)\n\n\(message)" as CVarArg) + #else os_log("%{public}@", log: osLog, type: osLogType, message as CVarArg) + #endif } public func debug( diff --git a/Tool/Sources/ModificationBasic/ExplanationThenCodeStreamParser.swift b/Tool/Sources/ModificationBasic/ExplanationThenCodeStreamParser.swift new file mode 100644 index 00000000..bb3a71e9 --- /dev/null +++ b/Tool/Sources/ModificationBasic/ExplanationThenCodeStreamParser.swift @@ -0,0 +1,269 @@ +import Foundation + +/// Parse a stream that contains explanation followed by a code block. +public actor ExplanationThenCodeStreamParser { + enum State { + case explanation + case code + case codeOpening + case codeClosing + } + + public enum Fragment: Sendable { + case explanation(String) + case code(String) + } + + struct Buffer { + var content: String = "" + } + + var _buffer: Buffer = .init() + var isAtBeginning = true + var buffer: String { _buffer.content } + var state: State = .explanation + let fullCodeDelimiter = "```" + + public init() {} + + private func appendBuffer(_ character: Character) { + _buffer.content.append(character) + } + + private func appendBuffer(_ content: String) { + _buffer.content += content + } + + private func resetBuffer() { + _buffer.content = "" + } + + func flushBuffer() -> String? { + if buffer.isEmpty { return nil } + guard let targetIndex = _buffer.content.lastIndex(where: { $0 != "`" && !$0.isNewline }) + else { return nil } + let prefix = _buffer.content[...targetIndex] + if prefix.isEmpty { return nil } + let nextIndex = _buffer.content.index( + targetIndex, + offsetBy: 1, + limitedBy: _buffer.content.endIndex + ) ?? _buffer.content.endIndex + + if nextIndex == _buffer.content.endIndex { + _buffer.content = "" + } else { + _buffer.content = String( + _buffer.content[nextIndex...] + ) + } + + // If we flushed something, we are no longer at the beginning + isAtBeginning = false + return String(prefix) + } + + func flushBufferIfNeeded(into results: inout [Fragment]) { + switch state { + case .explanation: + if let flushed = flushBuffer() { + results.append(.explanation(flushed)) + } + case .code: + if let flushed = flushBuffer() { + results.append(.code(flushed)) + } + case .codeOpening, .codeClosing: + break + } + } + + public func yield(_ fragment: String) -> [Fragment] { + var results: [Fragment] = [] + + func flushBuffer() { + flushBufferIfNeeded(into: &results) + } + + for character in fragment { + switch state { + case .explanation: + func forceFlush() { + if !buffer.isEmpty { + isAtBeginning = false + results.append(.explanation(buffer)) + resetBuffer() + } + } + + switch character { + case "`": + if let last = buffer.last, last == "`" || last.isNewline { + flushBuffer() + // if we are seeing the pattern of "\n`" or "``" + // that mean we may be hitting a code delimiter + appendBuffer(character) + let shouldOpenCodeBlock: Bool = { + guard buffer.hasSuffix(fullCodeDelimiter) + else { return false } + if isAtBeginning { return true } + let temp = String(buffer.dropLast(fullCodeDelimiter.count)) + if let last = temp.last, last.isNewline { + return true + } + return false + }() + // if we meet a code delimiter while in explanation state, + // it means we are opening a code block + if shouldOpenCodeBlock { + results.append(.explanation( + String(buffer.dropLast(fullCodeDelimiter.count)) + .trimmingTrailingCharacters(in: .whitespacesAndNewlines) + )) + resetBuffer() + state = .codeOpening + } + } else { + // Otherwise, the backtick is probably part of the explanation. + forceFlush() + appendBuffer(character) + } + case let char where char.isNewline: + // we keep the trailing new lines in case they are right + // ahead of the code block that should be ignored. + if let last = buffer.last, last.isNewline { + flushBuffer() + appendBuffer(character) + } else { + forceFlush() + appendBuffer(character) + } + default: + appendBuffer(character) + } + case .code: + func forceFlush() { + if !buffer.isEmpty { + isAtBeginning = false + results.append(.code(buffer)) + resetBuffer() + } + } + + switch character { + case "`": + if let last = buffer.last, last == "`" || last.isNewline { + flushBuffer() + // if we are seeing the pattern of "\n`" or "``" + // that mean we may be hitting a code delimiter + appendBuffer(character) + let possibleClosingDelimiter: String? = { + guard buffer.hasSuffix(fullCodeDelimiter) else { return nil } + let temp = String(buffer.dropLast(fullCodeDelimiter.count)) + if let last = temp.last, last.isNewline { + return "\(last)\(fullCodeDelimiter)" + } + return nil + }() + // if we meet a code delimiter while in code state, + // // it means we are closing the code block + if let possibleClosingDelimiter { + results.append(.code( + String(buffer.dropLast(possibleClosingDelimiter.count)) + )) + resetBuffer() + appendBuffer(possibleClosingDelimiter) + state = .codeClosing + } + } else { + // Otherwise, the backtick is probably part of the code. + forceFlush() + appendBuffer(character) + } + + case let char where char.isNewline: + if let last = buffer.last, last.isNewline { + flushBuffer() + appendBuffer(character) + } else { + forceFlush() + appendBuffer(character) + } + default: + appendBuffer(character) + } + case .codeOpening: + // skip the code block fence + if character.isNewline { + state = .code + } + case .codeClosing: + appendBuffer(character) + switch character { + case "`": + let possibleClosingDelimiter: String? = { + guard buffer.hasSuffix(fullCodeDelimiter) else { return nil } + let temp = String(buffer.dropLast(fullCodeDelimiter.count)) + if let last = temp.last, last.isNewline { + return "\(last)\(fullCodeDelimiter)" + } + return nil + }() + // if we meet another code delimiter while in codeClosing state, + // it means the previous code delimiter was part of the code + if let possibleClosingDelimiter { + results.append(.code( + String(buffer.dropLast(possibleClosingDelimiter.count)) + )) + resetBuffer() + appendBuffer(possibleClosingDelimiter) + } + default: + break + } + } + } + + flushBuffer() + + return results + } + + public func finish() -> [Fragment] { + guard !buffer.isEmpty else { return [] } + + var results: [Fragment] = [] + switch state { + case .explanation: + results.append( + .explanation(buffer.trimmingTrailingCharacters(in: .whitespacesAndNewlines)) + ) + case .code: + results.append(.code(buffer)) + case .codeClosing: + break + case .codeOpening: + break + } + resetBuffer() + + return results + } +} + +extension String { + func trimmingTrailingCharacters(in characterSet: CharacterSet) -> String { + guard !isEmpty else { + return "" + } + var unicodeScalars = unicodeScalars + while let scalar = unicodeScalars.last { + if !characterSet.contains(scalar) { + return String(unicodeScalars) + } + unicodeScalars.removeLast() + } + return "" + } +} + diff --git a/Tool/Sources/ModificationBasic/ModificationAgent.swift b/Tool/Sources/ModificationBasic/ModificationAgent.swift index 69a409f7..a29224af 100644 --- a/Tool/Sources/ModificationBasic/ModificationAgent.swift +++ b/Tool/Sources/ModificationBasic/ModificationAgent.swift @@ -5,6 +5,7 @@ import SuggestionBasic public enum ModificationAgentResponse { case code(String) + case explanation(String) } public struct ModificationAgentRequest { diff --git a/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIDefinition.swift b/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIDefinition.swift index 87a5e73e..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)" } } diff --git a/Tool/Sources/OpenAIService/APIs/ClaudeChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/ClaudeChatCompletionsService.swift index 548ca8a1..223eab79 100644 --- a/Tool/Sources/OpenAIService/APIs/ClaudeChatCompletionsService.swift +++ b/Tool/Sources/OpenAIService/APIs/ClaudeChatCompletionsService.swift @@ -7,6 +7,7 @@ import JoinJSON import Logger import Preferences +#warning("Update the definitions") /// https://docs.anthropic.com/claude/reference/messages_post public actor ClaudeChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI { /// https://docs.anthropic.com/en/docs/about-claude/models @@ -44,7 +45,7 @@ public actor ClaudeChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet } } - enum MessageRole: String, Codable { + public enum MessageRole: String, Codable { case user case assistant @@ -127,7 +128,7 @@ public actor ClaudeChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet var stop_sequence: String? } - public struct RequestBody: Encodable, Equatable { + public struct RequestBody: Codable, Equatable { public struct CacheControl: Codable, Equatable, Sendable { public enum CacheControlType: String, Codable, Equatable, Sendable { case ephemeral @@ -136,33 +137,33 @@ public actor ClaudeChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet public var type: CacheControlType = .ephemeral } - struct MessageContent: Encodable, Equatable { - enum MessageContentType: String, Encodable, Equatable { + public struct MessageContent: Codable, Equatable { + public enum MessageContentType: String, Codable, Equatable { case text case image } - struct ImageSource: Encodable, Equatable { - var type: String = "base64" + public struct ImageSource: Codable, Equatable { + public var type: String = "base64" /// currently support the base64 source type for images, /// and the image/jpeg, image/png, image/gif, and image/webp media types. - var media_type: String = "image/jpeg" - var data: String + public var media_type: String = "image/jpeg" + public var data: String } - var type: MessageContentType - var text: String? - var source: ImageSource? - var cache_control: CacheControl? + public var type: MessageContentType + public var text: String? + public var source: ImageSource? + public var cache_control: CacheControl? } - struct Message: Encodable, Equatable { + public struct Message: Codable, Equatable { /// The role of the message. - var role: MessageRole + public var role: MessageRole /// The content of the message. - var content: [MessageContent] + public var content: [MessageContent] - mutating func appendText(_ text: String) { + public mutating func appendText(_ text: String) { var otherContents = [MessageContent]() var existedText = "" for existed in content { @@ -182,26 +183,26 @@ public actor ClaudeChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet } } - struct SystemPrompt: Encodable, Equatable { - let type = "text" - var text: String - var cache_control: CacheControl? + public struct SystemPrompt: Codable, Equatable { + public var type = "text" + public var text: String + public var cache_control: CacheControl? } - struct Tool: Encodable, Equatable { - var name: String - var description: String - var input_schema: JSONSchemaValue + public struct Tool: Codable, Equatable { + public var name: String + public var description: String + public var input_schema: JSONSchemaValue } - var model: String - var system: [SystemPrompt] - var messages: [Message] - var temperature: Double? - var stream: Bool? - var stop_sequences: [String]? - var max_tokens: Int - var tools: [RequestBody.Tool]? + public var model: String + public var system: [SystemPrompt] + public var messages: [Message] + public var temperature: Double? + public var stream: Bool? + public var stop_sequences: [String]? + public var max_tokens: Int + public var tools: [RequestBody.Tool]? } var apiKey: String @@ -466,7 +467,7 @@ extension ClaudeChatCompletionsService.RequestBody { content.append(.init(type: .image, source: .init( type: "base64", media_type: image.format.rawValue, - data: image.data.base64EncodedString() + data: image.base64EncodeData ))) } @@ -521,5 +522,53 @@ extension ClaudeChatCompletionsService.RequestBody { stop_sequences = body.stop max_tokens = body.maxTokens ?? 4000 } + + func formalized() -> ChatCompletionsRequestBody { + return .init( + model: model, + messages: system.map { system in + let convertedMessage = ChatCompletionsRequestBody.Message( + role: .system, + content: system.text, + cacheIfPossible: system.cache_control != nil + ) + return convertedMessage + } + messages.map { message in + var convertedMessage = ChatCompletionsRequestBody.Message( + role: message.role == .user ? .user : .assistant, + content: "", + cacheIfPossible: message.content.contains(where: { $0.cache_control != nil }) + ) + for messageContent in message.content { + switch messageContent.type { + case .text: + if let text = messageContent.text { + convertedMessage.content += text + } + case .image: + if let source = messageContent.source { + convertedMessage.images.append( + .init( + base64EncodeData: source.data, + format: { + switch source.media_type { + case "image/png": return .png + case "image/gif": return .gif + default: return .jpeg + } + }() + ) + ) + } + } + } + return convertedMessage + }, + temperature: temperature, + stream: stream, + stop: stop_sequences, + maxTokens: max_tokens + ) + } } diff --git a/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift index c1ad2027..93c5987d 100644 --- a/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift +++ b/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift @@ -74,10 +74,12 @@ public actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet case assistant case function case tool + case developer var formalized: ChatCompletionsRequestBody.Message.Role { switch self { case .system: return .system + case .developer: return .system case .user: return .user case .assistant: return .assistant case .function: return .tool @@ -272,6 +274,14 @@ public actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet public struct RequestBody: Codable, Equatable { public typealias ClaudeCacheControl = ClaudeChatCompletionsService.RequestBody.CacheControl + public struct GitHubCopilotCacheControl: Codable, Equatable, Sendable { + public var type: String + + public init(type: String = "ephemeral") { + self.type = type + } + } + public struct Message: Codable, Equatable { public enum MessageContent: Codable, Equatable { public struct TextContentPart: Codable, Equatable { @@ -427,6 +437,14 @@ public actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet errors.append(error) } + do { // Null + _ = try container.decode([ContentPart]?.self) + self = .contentParts([]) + return + } catch { + errors.append(error) + } + struct E: Error, LocalizedError { let errors: [Error] @@ -457,6 +475,9 @@ public actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet /// /// Deprecated. public var function_call: MessageFunctionCall? + #warning("TODO: when to use it?") + /// Cache control for GitHub Copilot models. + public var copilot_cache_control: GitHubCopilotCacheControl? public init( role: MessageRole, @@ -464,7 +485,8 @@ public actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet name: String? = nil, tool_calls: [MessageToolCall]? = nil, tool_call_id: String? = nil, - function_call: MessageFunctionCall? = nil + function_call: MessageFunctionCall? = nil, + copilot_cache_control: GitHubCopilotCacheControl? = nil ) { self.role = role self.content = content @@ -472,6 +494,7 @@ public actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet self.tool_calls = tool_calls self.tool_call_id = tool_call_id self.function_call = function_call + self.copilot_cache_control = copilot_cache_control } } @@ -623,6 +646,7 @@ public actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet Self.setupCustomBody(&request, model: model) Self.setupAppInformation(&request) Self.setupAPIKey(&request, model: model, apiKey: apiKey) + Self.setupGitHubCopilotVisionField(&request, model: model) await Self.setupExtraHeaderFields(&request, model: model, apiKey: apiKey) requestModifier?(&request) @@ -772,6 +796,12 @@ public actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet } } + static func setupGitHubCopilotVisionField(_ request: inout URLRequest, model: ChatModel) { + if model.info.supportsImage { + request.setValue("true", forHTTPHeaderField: "copilot-vision-request") + } + } + static func setupCustomBody(_ request: inout URLRequest, model: ChatModel) { switch model.format { case .openAI, .openAICompatible: @@ -980,7 +1010,7 @@ extension OpenAIChatCompletionsService.RequestBody { ) { if supportsMultipartMessageContent { switch message.role { - case .system, .assistant, .user: + case .system, .developer, .assistant, .user: let newParts = Self.convertContentPart( content: content, images: images, @@ -1000,7 +1030,7 @@ extension OpenAIChatCompletionsService.RequestBody { } } else { switch message.role { - case .system, .assistant, .user: + case .system, .developer, .assistant, .user: if case let .text(existingText) = message.content { message.content = .text(existingText + "\n\n" + content) } else { @@ -1028,6 +1058,11 @@ extension OpenAIChatCompletionsService.RequestBody { supportsTemperature: Bool, supportsSystemPrompt: Bool ) { + let supportsMultipartMessageContent = if supportsAudio || supportsImage { + true + } else { + supportsMultipartMessageContent + } temperature = body.temperature stream = body.stream stop = body.stop @@ -1237,12 +1272,17 @@ extension OpenAIChatCompletionsService.RequestBody { } }(), content: { + // always prefer text only content if possible. if supportsMultipartMessageContent { - return .contentParts(Self.convertContentPart( - content: message.content, - images: supportsImage ? message.images : [], - audios: supportsAudio ? message.audios : [] - )) + let images = supportsImage ? message.images : [] + let audios = supportsAudio ? message.audios : [] + if !images.isEmpty || !audios.isEmpty { + return .contentParts(Self.convertContentPart( + content: message.content, + images: images, + audios: audios + )) + } } return .text(message.content) }(), diff --git a/Tool/Sources/OpenAIService/APIs/OpenAIResponsesRawService.swift b/Tool/Sources/OpenAIService/APIs/OpenAIResponsesRawService.swift new file mode 100644 index 00000000..818c4616 --- /dev/null +++ b/Tool/Sources/OpenAIService/APIs/OpenAIResponsesRawService.swift @@ -0,0 +1,235 @@ +import AIModel +import AsyncAlgorithms +import ChatBasic +import Foundation +import GitHubCopilotService +import JoinJSON +import Logger +import Preferences + +/// https://platform.openai.com/docs/api-reference/responses/create +public actor OpenAIResponsesRawService { + struct CompletionAPIError: Error, Decodable, LocalizedError { + struct ErrorDetail: Decodable { + var message: String + var type: String? + var param: String? + var code: String? + } + + struct MistralAIErrorMessage: Decodable { + struct Detail: Decodable { + var msg: String? + } + + var message: String? + var msg: String? + var detail: [Detail]? + } + + enum Message { + case raw(String) + case mistralAI(MistralAIErrorMessage) + } + + var error: ErrorDetail? + var message: Message + + var errorDescription: String? { + if let message = error?.message { return message } + switch message { + case let .raw(string): + return string + case let .mistralAI(mistralAIErrorMessage): + return mistralAIErrorMessage.message + ?? mistralAIErrorMessage.msg + ?? mistralAIErrorMessage.detail?.first?.msg + ?? "Unknown Error" + } + } + + enum CodingKeys: String, CodingKey { + case error + case message + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + error = try container.decode(ErrorDetail.self, forKey: .error) + message = { + if let e = try? container.decode(MistralAIErrorMessage.self, forKey: .message) { + return CompletionAPIError.Message.mistralAI(e) + } + if let e = try? container.decode(String.self, forKey: .message) { + return .raw(e) + } + return .raw("Unknown Error") + }() + } + } + + var apiKey: String + var endpoint: URL + var requestBody: [String: Any] + var model: ChatModel + let requestModifier: ((inout URLRequest) -> Void)? + + public init( + apiKey: String, + model: ChatModel, + endpoint: URL, + requestBody: Data, + requestModifier: ((inout URLRequest) -> Void)? = nil + ) { + self.apiKey = apiKey + self.endpoint = endpoint + self.requestBody = ( + try? JSONSerialization.jsonObject(with: requestBody) as? [String: Any] + ) ?? [:] + self.requestBody["model"] = model.info.modelName + self.model = model + self.requestModifier = requestModifier + } + + public func callAsFunction() async throws + -> URLSession.AsyncBytes + { + requestBody["stream"] = true + var request = URLRequest(url: endpoint) + request.httpMethod = "POST" + request.httpBody = try JSONSerialization.data( + withJSONObject: requestBody, + options: [] + ) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + Self.setupAppInformation(&request) + await Self.setupAPIKey(&request, model: model, apiKey: apiKey) + Self.setupGitHubCopilotVisionField(&request, model: model) + await Self.setupExtraHeaderFields(&request, model: model, apiKey: apiKey) + requestModifier?(&request) + + let (result, response) = try await URLSession.shared.bytes(for: request) + guard let response = response as? HTTPURLResponse else { + throw ChatGPTServiceError.responseInvalid + } + + guard response.statusCode == 200 else { + let text = try await result.lines.reduce(into: "") { partialResult, current in + partialResult += current + } + guard let data = text.data(using: .utf8) + else { throw ChatGPTServiceError.responseInvalid } + if response.statusCode == 403 { + throw ChatGPTServiceError.unauthorized(text) + } + let decoder = JSONDecoder() + let error = try? decoder.decode(CompletionAPIError.self, from: data) + throw error ?? ChatGPTServiceError.otherError( + text + + "\n\nPlease check your model settings, some capabilities may not be supported by the model." + ) + } + + return result + } + + public func callAsFunction() async throws -> Data { + let stream: URLSession.AsyncBytes = try await callAsFunction() + + return try await stream.reduce(into: Data()) { partialResult, byte in + partialResult.append(byte) + } + } + + static func setupAppInformation(_ request: inout URLRequest) { + if #available(macOS 13.0, *) { + if request.url?.host == "openrouter.ai" { + request.setValue("Copilot for Xcode", forHTTPHeaderField: "X-Title") + request.setValue( + "https://github.com/intitni/CopilotForXcode", + forHTTPHeaderField: "HTTP-Referer" + ) + } + } else { + if request.url?.host == "openrouter.ai" { + request.setValue("Copilot for Xcode", forHTTPHeaderField: "X-Title") + request.setValue( + "https://github.com/intitni/CopilotForXcode", + forHTTPHeaderField: "HTTP-Referer" + ) + } + } + } + + static func setupAPIKey(_ request: inout URLRequest, model: ChatModel, apiKey: String) async { + if !apiKey.isEmpty { + switch model.format { + case .openAI: + if !model.info.openAIInfo.organizationID.isEmpty { + request.setValue( + model.info.openAIInfo.organizationID, + forHTTPHeaderField: "OpenAI-Organization" + ) + } + + if !model.info.openAIInfo.projectID.isEmpty { + request.setValue( + model.info.openAIInfo.projectID, + forHTTPHeaderField: "OpenAI-Project" + ) + } + + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + case .openAICompatible: + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + case .azureOpenAI: + request.setValue(apiKey, forHTTPHeaderField: "api-key") + case .gitHubCopilot: + break + case .googleAI: + assertionFailure("Unsupported") + case .ollama: + assertionFailure("Unsupported") + case .claude: + assertionFailure("Unsupported") + } + } + + if model.format == .gitHubCopilot, + let token = try? await GitHubCopilotExtension.fetchToken() + { + request.setValue( + "Copilot for Xcode/\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown")", + forHTTPHeaderField: "Editor-Version" + ) + request.setValue("Bearer \(token.token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("vscode-chat", forHTTPHeaderField: "Copilot-Integration-Id") + request.setValue("2023-07-07", forHTTPHeaderField: "X-Github-Api-Version") + } + } + + static func setupGitHubCopilotVisionField(_ request: inout URLRequest, model: ChatModel) { + if model.info.supportsImage { + request.setValue("true", forHTTPHeaderField: "copilot-vision-request") + } + } + + static func setupExtraHeaderFields( + _ request: inout URLRequest, + model: ChatModel, + apiKey: String + ) async { + let parser = HeaderValueParser() + for field in model.info.customHeaderInfo.headers where !field.key.isEmpty { + let value = await parser.parse( + field.value, + context: .init(modelName: model.info.modelName, apiKey: apiKey) + ) + request.setValue(value, forHTTPHeaderField: field.key) + } + } +} + diff --git a/Tool/Sources/OpenAIService/ChatGPTService.swift b/Tool/Sources/OpenAIService/ChatGPTService.swift index 45d6490d..b02f4f2b 100644 --- a/Tool/Sources/OpenAIService/ChatGPTService.swift +++ b/Tool/Sources/OpenAIService/ChatGPTService.swift @@ -578,6 +578,18 @@ extension ChatGPTService { } 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: { @@ -605,7 +617,7 @@ extension ChatGPTService { nil } }(), - images: [], + images: images, audios: [], cacheIfPossible: chatMessage.cacheIfPossible )) 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/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 ec392514..7b955d50 100644 --- a/Tool/Sources/Preferences/Keys.swift +++ b/Tool/Sources/Preferences/Keys.swift @@ -100,6 +100,11 @@ public struct UserDefaultPreferenceKeys { defaultValue: false, key: "InstallBetaBuilds" ) + + public let debugOverlayPanel = PreferenceKey( + defaultValue: false, + key: "DebugOverlayPanel" + ) } // MARK: - OpenAI Account Settings @@ -375,7 +380,7 @@ public extension UserDefaultPreferenceKeys { var acceptSuggestionWithModifierOnlyForSwift: PreferenceKey { .init(defaultValue: false, key: "SuggestionWithModifierOnlyForSwift") } - + var acceptSuggestionLineWithModifierControl: PreferenceKey { .init(defaultValue: true, key: "SuggestionLineWithModifierControl") } @@ -486,7 +491,7 @@ public extension UserDefaultPreferenceKeys { var preferredChatModelIdForUtilities: PreferenceKey { .init(defaultValue: "", key: "PreferredChatModelIdForUtilities") } - + enum ChatPanelFloatOnTopOption: Int, Codable, Equatable { case alwaysOnTop case onTopWhenXcodeIsActive @@ -589,37 +594,37 @@ public extension UserDefaultPreferenceKeys { case headlessBrowser case serpAPI } - + enum SerpAPIEngine: String, Codable, CaseIterable { case google case baidu case bing case duckDuckGo = "duckduckgo" } - + enum HeadlessBrowserEngine: String, Codable, CaseIterable { case google case baidu case bing case duckDuckGo = "duckduckgo" } - + var searchProvider: PreferenceKey { .init(defaultValue: .headlessBrowser, key: "SearchProvider") } - + var serpAPIEngine: PreferenceKey { .init(defaultValue: .google, key: "SerpAPIEngine") } - + var serpAPIKeyName: PreferenceKey { .init(defaultValue: "", key: "SerpAPIKeyName") } - + var headlessBrowserEngine: PreferenceKey { .init(defaultValue: .google, key: "HeadlessBrowserEngine") } - + var bingSearchSubscriptionKey: DeprecatedPreferenceKey { .init(defaultValue: "", key: "BingSearchSubscriptionKey") } diff --git a/Tool/Sources/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 d9979890..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,6 +23,7 @@ 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( @@ -104,8 +105,12 @@ public struct EditorInformation { inside range: CursorRange, ignoreColumns: Bool = false ) -> (code: String, lines: [String]) { + if range.start == range.end { + // Empty selection (cursor only): return empty code but include the containing line + return ("", lines(in: code, containing: range)) + } guard range.start < range.end else { return ("", []) } - + let rangeLines = lines(in: code, containing: range) if ignoreColumns { return (rangeLines.joined(), rangeLines) diff --git a/Tool/Sources/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 index 78f0a120..e7c45725 100644 --- a/Tool/Sources/WebScrapper/WebScrapper.swift +++ b/Tool/Sources/WebScrapper/WebScrapper.swift @@ -6,9 +6,11 @@ import WebKit public final class WebScrapper { final class NavigationDelegate: NSObject, WKNavigationDelegate { weak var scrapper: WebScrapper? - - public nonisolated func webView(_: WKWebView, didFinish _: WKNavigation!) { + + 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 } } @@ -29,7 +31,7 @@ public final class WebScrapper { var webViewDidFinishLoading = false var navigationError: (any Error)? - let navigationDelegate: NavigationDelegate = NavigationDelegate() + let navigationDelegate: NavigationDelegate = .init() enum WebScrapperError: Error { case retry @@ -38,15 +40,6 @@ public final class WebScrapper { public init() async { let jsonRuleList = ###""" [ - { - "trigger": { - "url-filter": ".*", - "resource-type": ["style-sheet"] - }, - "action": { - "type": "block" - } - }, { "trigger": { "url-filter": ".*", @@ -91,9 +84,8 @@ public final class WebScrapper { 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/16.5 Safari/605.1.15" + 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 @@ -134,8 +126,18 @@ public final class WebScrapper { retryCount += 1 try await Task.sleep(nanoseconds: 100_000_000) } - - throw CancellationError() + + 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 { 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 28a7643d..819f1ecc 100644 --- a/Tool/Sources/Workspace/WorkspacePool.swift +++ b/Tool/Sources/Workspace/WorkspacePool.swift @@ -61,6 +61,15 @@ public class WorkspacePool { } public func fetchFilespaceIfExisted(fileURL: URL) -> Filespace? { + // We prefer to get the filespace from the current active workspace. + // Just incase there are multiple workspaces opened with the same file. + if let currentWorkspaceURL = XcodeInspector.shared.realtimeActiveWorkspaceURL { + if let workspace = workspaces[currentWorkspaceURL], + let filespace = workspace.filespaces[fileURL] + { + return filespace + } + } for workspace in workspaces.values { if let filespace = workspace.filespaces[fileURL] { return filespace diff --git a/Tool/Sources/XcodeInspector/AppInstanceInspector.swift b/Tool/Sources/XcodeInspector/AppInstanceInspector.swift index ab358b7e..cd14dc13 100644 --- a/Tool/Sources/XcodeInspector/AppInstanceInspector.swift +++ b/Tool/Sources/XcodeInspector/AppInstanceInspector.swift @@ -2,7 +2,7 @@ import AppKit import Foundation open class AppInstanceInspector: @unchecked Sendable { - let runningApplication: NSRunningApplication + public let runningApplication: NSRunningApplication public let processIdentifier: pid_t public let bundleURL: URL? public let bundleIdentifier: String? diff --git a/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift index 5e73578b..33291631 100644 --- a/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift +++ b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift @@ -245,7 +245,7 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector, @unchecked S } } } else { - let window = XcodeWindowInspector(uiElement: window) + let window = XcodeWindowInspector(app: runningApplication, uiElement: window) focusedWindow = window } } else { @@ -318,14 +318,25 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector, @unchecked S )) } case .uiElementDestroyed: - if isCompletionPanel(notification.element) { - await MainActor.run { - self.completionPanel = nil + let completionPanel = await self.completionPanel + if let completionPanel { + if isCompletionPanel(notification.element) { + await MainActor.run { + self.completionPanel = nil + } + self.axNotifications.send(.init( + kind: .xcodeCompletionPanelChanged, + element: notification.element + )) + } else if completionPanel.parent == nil { + await MainActor.run { + self.completionPanel = nil + } + self.axNotifications.send(.init( + kind: .xcodeCompletionPanelChanged, + element: notification.element + )) } - self.axNotifications.send(.init( - kind: .xcodeCompletionPanelChanged, - element: notification.element - )) } default: continue } @@ -380,6 +391,22 @@ extension XcodeAppInstanceInspector { let workspaces = Self.updateWorkspace(workspaces, with: workspaceInfoInVisibleSpace) self.workspaces = workspaces } + + public func workspaceWindow( + forWorkspaceURL url: URL + ) -> AXUIElement? { + let windows = appElement.windows.filter { $0.identifier == "Xcode.WorkspaceWindow" } + + for window in windows { + if let workspaceURL = WorkspaceXcodeWindowInspector + .extractWorkspaceURL(windowElement: window), + workspaceURL == url + { + return window + } + } + return nil + } /// Use the project path as the workspace identifier. nonisolated static func workspaceIdentifier(_ window: AXUIElement) -> WorkspaceIdentifier { @@ -414,7 +441,7 @@ extension XcodeAppInstanceInspector { allTabs.insert(element.title) return .skipDescendants } - return .continueSearching + return .continueSearching(()) } } return allTabs @@ -469,14 +496,31 @@ private func isCompletionPanel(_ element: AXUIElement) -> Bool { } public extension AXUIElement { + var editorArea: AXUIElement? { + if description == "editor area" { return self } + var area: AXUIElement? + traverse { element, level in + if level > 10 { + return .skipDescendants + } + if element.description == "editor area" { + area = element + return .stopSearching + } + if element.description == "navigator" { + return .skipDescendants + } + + return .continueSearching(()) + } + return area + } + var tabBars: [AXUIElement] { - guard let editArea: AXUIElement = { - if description == "editor area" { return self } - return firstChild(where: { $0.description == "editor area" }) - }() else { return [] } + guard let editorArea else { return [] } var tabBars = [AXUIElement]() - editArea.traverse { element, _ in + editorArea.traverse { element, _ in let description = element.description if description == "Tab Bar" { element.traverse { element, _ in @@ -484,7 +528,7 @@ public extension AXUIElement { tabBars.append(element) return .stopSearching } - return .continueSearching + return .continueSearching(()) } return .skipDescendantsAndSiblings @@ -510,20 +554,17 @@ public extension AXUIElement { return .skipDescendants } - return .continueSearching + return .continueSearching(()) } return tabBars } var debugArea: AXUIElement? { - guard let editArea: AXUIElement = { - if description == "editor area" { return self } - return firstChild(where: { $0.description == "editor area" }) - }() else { return nil } + guard let editorArea else { return nil } var debugArea: AXUIElement? - editArea.traverse { element, _ in + editorArea.traverse { element, _ in let description = element.description if description == "Tab Bar" { return .skipDescendants @@ -550,7 +591,7 @@ public extension AXUIElement { return .skipDescendants } - return .continueSearching + return .continueSearching(()) } return debugArea diff --git a/Tool/Sources/XcodeInspector/XcodeInspector.swift b/Tool/Sources/XcodeInspector/XcodeInspector.swift index 3f97c637..c26b7c7b 100644 --- a/Tool/Sources/XcodeInspector/XcodeInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeInspector.swift @@ -154,6 +154,9 @@ public final class XcodeInspector: Sendable { NotificationCenter.default.post(name: .focusedEditorDidChange, object: nil) } } + + @MainActor + public fileprivate(set) var latestFocusedEditor: SourceEditor? @MainActor public fileprivate(set) var focusedElement: AXUIElement? { @@ -172,7 +175,7 @@ public final class XcodeInspector: Sendable { let projectURL = realtimeActiveProjectURL else { return nil } - let editorContent = await focusedEditor?.getContent() + let editorContent = await latestFocusedEditor?.getContent() let language = languageIdentifierFromFileURL(documentURL) let relativePath = documentURL.path.replacingOccurrences(of: projectURL.path, with: "") @@ -234,6 +237,7 @@ public final class XcodeInspector: Sendable { latestActiveXcode = nil activeApplication = nil focusedEditor = nil + latestFocusedEditor = nil focusedElement = nil } } @@ -249,6 +253,7 @@ public final class XcodeInspector: Sendable { activeApplication = activeXcode ?? runningApplications .first(where: \.isActive) .map(AppInstanceInspector.init(runningApplication:)) + self.activeXcode = activeXcode } appChangeObservations.forEach { $0.cancel() } @@ -388,6 +393,7 @@ public final class XcodeInspector: Sendable { runningApplication: xcode.runningApplication, element: editorElement ) + self.latestFocusedEditor = self.focusedEditor } else if let element = self.focusedElement, let editorElement = element.firstParent(where: \.isSourceEditor) { @@ -395,6 +401,7 @@ public final class XcodeInspector: Sendable { runningApplication: xcode.runningApplication, element: editorElement ) + self.latestFocusedEditor = self.focusedEditor } else { self.focusedEditor = nil } diff --git a/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift b/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift index f23087cc..c63b3f71 100644 --- a/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift @@ -2,23 +2,47 @@ import AppKit import AsyncPassthroughSubject import AXExtension import Combine +import CoreGraphics import Foundation import Logger import Perception public class XcodeWindowInspector { - public let uiElement: AXUIElement + public let app: NSRunningApplication + public let windowID: CGWindowID + public var uiElement: AXUIElement { + let windowID = self.windowID + if _uiElement.parent == nil { + let app = AXUIElementCreateApplication(app.processIdentifier) + app.setMessagingTimeout(2) + if let newWindowElement = app.windows.first(where: { $0.windowID == windowID }) { + self._uiElement = newWindowElement + newWindowElement.setMessagingTimeout(2) + } + } + return _uiElement + } - init(uiElement: AXUIElement) { - self.uiElement = uiElement + var _uiElement: AXUIElement + + init( + app: NSRunningApplication, + uiElement: AXUIElement + ) { + self.app = app + _uiElement = uiElement uiElement.setMessagingTimeout(2) + windowID = uiElement.windowID ?? 0 + } + + public var isInvalid: Bool { + uiElement.parent == nil } } @XcodeInspectorActor @Perceptible public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector, Sendable { - let app: NSRunningApplication @MainActor public private(set) var documentURL: URL = .init(fileURLWithPath: "/") @MainActor @@ -37,8 +61,7 @@ public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector, Sendable uiElement: AXUIElement, axNotifications: AsyncPassthroughSubject ) { - self.app = app - super.init(uiElement: uiElement) + super.init(app: app, uiElement: uiElement) focusedElementChangedTask = Task { @MainActor [weak self, axNotifications] in self?.updateURLs() diff --git a/Tool/Tests/ModificationBasicTests/ExplanationThenCodeStreamParserTests.swift b/Tool/Tests/ModificationBasicTests/ExplanationThenCodeStreamParserTests.swift new file mode 100644 index 00000000..1597c553 --- /dev/null +++ b/Tool/Tests/ModificationBasicTests/ExplanationThenCodeStreamParserTests.swift @@ -0,0 +1,289 @@ +import Foundation +import XCTest +@testable import ModificationBasic + +class ExplanationThenCodeStreamParserTests: XCTestCase { + func collectFragments(_ fragments: [ExplanationThenCodeStreamParser.Fragment]) -> ( + code: String, + explanation: String + ) { + var code = "" + var explanation = "" + for fragment in fragments { + switch fragment { + case let .code(c): + code += c + case let .explanation(e): + explanation += e + } + } + return (code: code, explanation: explanation) + } + + func process(_ code: String) async -> (code: String, explanation: String) { + let parser = ExplanationThenCodeStreamParser() + var allFragments: [ExplanationThenCodeStreamParser.Fragment] = [] + + func chunks(from code: String, chunkSize: Int) -> [String] { + var chunks: [String] = [] + var currentIndex = code.startIndex + + while currentIndex < code.endIndex { + let endIndex = code.index( + currentIndex, + offsetBy: chunkSize, + limitedBy: code.endIndex + ) ?? code.endIndex + let chunk = String(code[currentIndex..