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/.gitignore b/ChatPlugins/.gitignore
new file mode 100644
index 00000000..0023a534
--- /dev/null
+++ b/ChatPlugins/.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/ChatPlugins/.swiftpm/xcode/xcshareddata/xcschemes/ChatPlugins.xcscheme b/ChatPlugins/.swiftpm/xcode/xcshareddata/xcschemes/ChatPlugins.xcscheme
new file mode 100644
index 00000000..53df9491
--- /dev/null
+++ b/ChatPlugins/.swiftpm/xcode/xcshareddata/xcschemes/ChatPlugins.xcscheme
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ChatPlugins/Package.swift b/ChatPlugins/Package.swift
new file mode 100644
index 00000000..4defd772
--- /dev/null
+++ b/ChatPlugins/Package.swift
@@ -0,0 +1,37 @@
+// swift-tools-version: 5.8
+// The swift-tools-version declares the minimum version of Swift required to build this package.
+
+import PackageDescription
+
+let package = Package(
+ name: "ChatPlugins",
+ platforms: [.macOS(.v12)],
+ products: [
+ .library(
+ name: "ChatPlugins",
+ targets: ["TerminalChatPlugin", "ShortcutChatPlugin"]
+ ),
+ ],
+ dependencies: [
+ .package(path: "../Tool"),
+ ],
+ targets: [
+ .target(
+ name: "TerminalChatPlugin",
+ dependencies: [
+ .product(name: "Chat", package: "Tool"),
+ .product(name: "Terminal", package: "Tool"),
+ .product(name: "AppMonitoring", package: "Tool"),
+ ]
+ ),
+ .target(
+ name: "ShortcutChatPlugin",
+ dependencies: [
+ .product(name: "Chat", package: "Tool"),
+ .product(name: "Terminal", package: "Tool"),
+ .product(name: "AppMonitoring", package: "Tool"),
+ ]
+ ),
+ ]
+)
+
diff --git a/ChatPlugins/Sources/ShortcutChatPlugin/ShortcutChatPlugin.swift b/ChatPlugins/Sources/ShortcutChatPlugin/ShortcutChatPlugin.swift
new file mode 100644
index 00000000..fc9d8d5b
--- /dev/null
+++ b/ChatPlugins/Sources/ShortcutChatPlugin/ShortcutChatPlugin.swift
@@ -0,0 +1,147 @@
+import ChatBasic
+import Foundation
+import Terminal
+
+public final class ShortcutChatPlugin: ChatPlugin {
+ public static var id: String { "com.intii.shortcut" }
+ public static var command: String { "shortcut" }
+ public static var name: String { "Shortcut" }
+ public static var description: String { """
+ Run a shortcut and use message content as input. You need to provide the shortcut name as an argument, for example, `/shortcut(Shortcut Name)`.
+ """ }
+
+ let terminal: TerminalType
+
+ init(terminal: TerminalType) {
+ self.terminal = terminal
+ }
+
+ public init() {
+ terminal = Terminal()
+ }
+
+ 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)"
+
+ guard let shortcutName = request.arguments.first, !shortcutName.isEmpty else {
+ continuation.yield(.content(.text(
+ "Please provide the shortcut name in format: `/\(Self.command)(shortcut name)`"
+ )))
+ return
+ }
+
+ var input = String(request.text).trimmingCharacters(in: .whitespacesAndNewlines)
+ if input.isEmpty {
+ // if no input detected, use the previous message as input
+ input = request.history.last?.content ?? ""
+ }
+
+ do {
+ continuation.yield(.startAction(
+ id: "run",
+ task: "Run shortcut `\(shortcutName)`"
+ ))
+
+ let env = ProcessInfo.processInfo.environment
+ let shell = env["SHELL"] ?? "/bin/bash"
+ let temporaryURL = FileManager.default.temporaryDirectory
+ let temporaryInputFileURL = temporaryURL
+ .appendingPathComponent("\(id)-input.txt")
+ let temporaryOutputFileURL = temporaryURL
+ .appendingPathComponent("\(id)-output")
+
+ try input.write(to: temporaryInputFileURL, atomically: true, encoding: .utf8)
+
+ let command = """
+ shortcuts run "\(shortcutName)" \
+ -i "\(temporaryInputFileURL.path)" \
+ -o "\(temporaryOutputFileURL.path)"
+ """
+
+ continuation.yield(.startAction(
+ id: "run",
+ task: "Run shortcut \(shortcutName)"
+ ))
+
+ do {
+ let result = try await terminal.runCommand(
+ shell,
+ arguments: ["-i", "-l", "-c", command],
+ currentDirectoryURL: nil,
+ environment: [:]
+ )
+ continuation.yield(.finishAction(id: "run", result: .success(result)))
+ } catch {
+ continuation.yield(.finishAction(
+ id: "run",
+ result: .failure(error.localizedDescription)
+ ))
+ throw error
+ }
+
+ await Task.yield()
+ try Task.checkCancellation()
+
+ if FileManager.default.fileExists(atPath: temporaryOutputFileURL.path) {
+ let data = try Data(contentsOf: temporaryOutputFileURL)
+ if let text = String(data: data, encoding: .utf8) {
+ var response = text
+ if response.isEmpty {
+ response = "Finished"
+ }
+ continuation.yield(.content(.text(response)))
+ } else {
+ let content = """
+ [View File](\(temporaryOutputFileURL))
+ """
+ continuation.yield(.content(.text(content)))
+ }
+ } else {
+ continuation.yield(.content(.text("Finished")))
+ }
+
+ } catch {
+ continuation.yield(.content(.text(error.localizedDescription)))
+ }
+
+ continuation.finish()
+ }
+
+ continuation.onTermination = { _ in
+ task.cancel()
+ }
+ }
+ }
+}
+
diff --git a/ChatPlugins/Sources/TerminalChatPlugin/TerminalChatPlugin.swift b/ChatPlugins/Sources/TerminalChatPlugin/TerminalChatPlugin.swift
new file mode 100644
index 00000000..1360b16d
--- /dev/null
+++ b/ChatPlugins/Sources/TerminalChatPlugin/TerminalChatPlugin.swift
@@ -0,0 +1,165 @@
+import ChatBasic
+import Foundation
+import Terminal
+import XcodeInspector
+
+public final class TerminalChatPlugin: ChatPlugin {
+ public static var id: String { "com.intii.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 shell.
+
+ You can use environment variable `$FILE_PATH` and `$PROJECT_ROOT` to access the current file path and project root.
+ """ }
+
+ let terminal: TerminalType
+
+ init(terminal: TerminalType) {
+ self.terminal = terminal
+ }
+
+ public init() {
+ terminal = Terminal()
+ }
+
+ public func getTextContent(from request: Request) async
+ -> AsyncStream
+ {
+ return .init { continuation in
+ let task = Task {
+ do {
+ let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL
+ let projectURL = XcodeInspector.shared.realtimeActiveProjectURL
+
+ var environment = [String: String]()
+ if let fileURL {
+ environment["FILE_PATH"] = fileURL.path
+ }
+ if let projectURL {
+ environment["PROJECT_ROOT"] = projectURL.path
+ }
+
+ try Task.checkCancellation()
+
+ let env = ProcessInfo.processInfo.environment
+ let shell = env["SHELL"] ?? "/bin/bash"
+
+ let output = terminal.streamCommand(
+ shell,
+ arguments: ["-i", "-l", "-c", request.text],
+ currentDirectoryURL: projectURL,
+ environment: environment
+ )
+
+ var accumulatedOutput = ""
+ for try await content in output {
+ try Task.checkCancellation()
+ accumulatedOutput += content
+ continuation.yield(accumulatedOutput)
+ }
+ } catch let error as Terminal.TerminationError {
+ let errorMessage = "\n\n[error: \(error.reason)]"
+ continuation.yield(errorMessage)
+ } catch {
+ let errorMessage = "\n\n[error: \(error.localizedDescription)]"
+ continuation.yield(errorMessage)
+ }
+
+ continuation.finish()
+ }
+
+ continuation.onTermination = { _ in
+ task.cancel()
+ Task {
+ await self.terminal.terminate()
+ }
+ }
+ }
+ }
+
+ 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/ChatPlugins/Tests/ChatPluginsTests/ChatPluginsTests.swift b/ChatPlugins/Tests/ChatPluginsTests/ChatPluginsTests.swift
new file mode 100644
index 00000000..90f1d16f
--- /dev/null
+++ b/ChatPlugins/Tests/ChatPluginsTests/ChatPluginsTests.swift
@@ -0,0 +1,6 @@
+import Testing
+@testable import ChatPlugins
+
+@Test func example() async throws {
+ // Write your test here and use APIs like `#expect(...)` to check expected conditions.
+}
diff --git a/Copilot for Xcode.xcodeproj/project.pbxproj b/Copilot for Xcode.xcodeproj/project.pbxproj
index ad068512..056e5761 100644
--- a/Copilot for Xcode.xcodeproj/project.pbxproj
+++ b/Copilot for Xcode.xcodeproj/project.pbxproj
@@ -205,6 +205,7 @@
C8216B772980370100AD38C7 /* ReloadLaunchAgent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReloadLaunchAgent.swift; sourceTree = ""; };
C828B27D2B1F241500E7612A /* ExtensionPoint.appextensionpoint */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = ExtensionPoint.appextensionpoint; sourceTree = ""; };
C82E38492A1F025F00D4EADF /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE; sourceTree = ""; };
+ C84FD9D72CC671C600BE5093 /* ChatPlugins */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = ChatPlugins; sourceTree = ""; };
C8520300293C4D9000460097 /* Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Helpers.swift; sourceTree = ""; };
C8520308293D805800460097 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; };
C861A6A229E5503F005C41A3 /* PromptToCodeCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptToCodeCommand.swift; sourceTree = ""; };
@@ -234,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 = ""; };
@@ -341,6 +343,8 @@
C81458AE293A009800135263 /* Config.debug.xcconfig */,
C8CD828229B88006008D044D /* TestPlan.xctestplan */,
C828B27D2B1F241500E7612A /* ExtensionPoint.appextensionpoint */,
+ C8BE64922EB9B42E00EDB2D7 /* OverlayWindow */,
+ C84FD9D72CC671C600BE5093 /* ChatPlugins */,
C81D181E2A1B509B006C1B70 /* Tool */,
C8189B282938979000C9DCDA /* Core */,
C8189B182938972F00C9DCDA /* Copilot for Xcode */,
@@ -775,14 +779,14 @@
DEVELOPMENT_TEAM = 5YKZ4Y3DAW;
ENABLE_HARDENED_RUNTIME = YES;
INFOPLIST_FILE = EditorExtension/Info.plist;
- INFOPLIST_KEY_CFBundleDisplayName = "$(EXTESNION_BUNDLE_NAME)";
+ INFOPLIST_KEY_CFBundleDisplayName = "$(EXTENSION_BUNDLE_NAME)";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@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;
@@ -803,14 +807,14 @@
DEVELOPMENT_TEAM = 5YKZ4Y3DAW;
ENABLE_HARDENED_RUNTIME = YES;
INFOPLIST_FILE = EditorExtension/Info.plist;
- INFOPLIST_KEY_CFBundleDisplayName = "$(EXTESNION_BUNDLE_NAME)";
+ INFOPLIST_KEY_CFBundleDisplayName = "$(EXTENSION_BUNDLE_NAME)";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@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;
@@ -956,13 +960,14 @@
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Copilot-for-Xcode-Info.plist";
+ INFOPLIST_KEY_CFBundleDisplayName = "";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
- MACOSX_DEPLOYMENT_TARGET = 12.0;
+ MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = "$(APP_VERSION)";
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE)";
PRODUCT_MODULE_NAME = Copilot_for_Xcode;
@@ -989,13 +994,14 @@
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Copilot-for-Xcode-Info.plist";
+ INFOPLIST_KEY_CFBundleDisplayName = "";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
- MACOSX_DEPLOYMENT_TARGET = 12.0;
+ MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = "$(APP_VERSION)";
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE)";
PRODUCT_NAME = "$(HOST_APP_NAME)";
@@ -1011,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;
@@ -1025,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;
@@ -1055,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)";
@@ -1088,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)";
@@ -1108,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;
@@ -1127,7 +1133,7 @@
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
- MACOSX_DEPLOYMENT_TARGET = 12.0;
+ MACOSX_DEPLOYMENT_TARGET = 13.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).CommunicationBridge";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
diff --git a/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved
index a0493266..87fd4d4e 100644
--- a/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved
+++ b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -1,5 +1,23 @@
{
"pins" : [
+ {
+ "identity" : "aexml",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/tadija/AEXML.git",
+ "state" : {
+ "revision" : "db806756c989760b35108146381535aec231092b",
+ "version" : "4.7.0"
+ }
+ },
+ {
+ "identity" : "cgeventoverride",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/intitni/CGEventOverride",
+ "state" : {
+ "revision" : "571d36d63e68fac30e4a350600cd186697936f74",
+ "version" : "1.2.3"
+ }
+ },
{
"identity" : "codablewrappers",
"kind" : "remoteSourceControl",
@@ -14,8 +32,17 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/combine-schedulers",
"state" : {
- "revision" : "0625932976b3ae23949f6b816d13bd97f3b40b7c",
- "version" : "0.10.0"
+ "revision" : "5928286acce13def418ec36d05a001a9641086f2",
+ "version" : "1.0.3"
+ }
+ },
+ {
+ "identity" : "copilotforxcodekit",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/intitni/CopilotForXcodeKit",
+ "state" : {
+ "branch" : "feature/custom-chat-tab",
+ "revision" : "63915ee1f8aba5375bc0f0166c8645fe81fe5b88"
}
},
{
@@ -30,10 +57,10 @@
{
"identity" : "generative-ai-swift",
"kind" : "remoteSourceControl",
- "location" : "https://github.com/google/generative-ai-swift",
+ "location" : "https://github.com/intitni/generative-ai-swift",
"state" : {
- "revision" : "f4a88085d5a6c1108f5a1aead83d19d02df8328d",
- "version" : "0.4.9"
+ "branch" : "support-setting-base-url",
+ "revision" : "12d7b30b566a64cc0dd628130bfb99a07368fea7"
}
},
{
@@ -50,8 +77,17 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/intitni/Highlightr",
"state" : {
- "branch" : "bump-highlight-js-version",
- "revision" : "4ffbb1b0b721378263297cafea6f2838044eb1eb"
+ "branch" : "master",
+ "revision" : "81d8c8b3733939bf5d9e52cd6318f944cc033bd2"
+ }
+ },
+ {
+ "identity" : "indexstore-db",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/indexstore-db.git",
+ "state" : {
+ "branch" : "release/6.1",
+ "revision" : "54212fce1aecb199070808bdb265e7f17e396015"
}
},
{
@@ -90,6 +126,24 @@
"version" : "0.8.0"
}
},
+ {
+ "identity" : "messagepacker",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/hirotakan/MessagePacker.git",
+ "state" : {
+ "revision" : "4d8346c6bc579347e4df0429493760691c5aeca2",
+ "version" : "0.4.7"
+ }
+ },
+ {
+ "identity" : "networkimage",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/gonzalezreal/NetworkImage",
+ "state" : {
+ "revision" : "2849f5323265386e200484b0d0f896e73c3411b9",
+ "version" : "6.0.1"
+ }
+ },
{
"identity" : "operationplus",
"kind" : "remoteSourceControl",
@@ -99,6 +153,15 @@
"version" : "1.6.0"
}
},
+ {
+ "identity" : "pathkit",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/kylef/PathKit.git",
+ "state" : {
+ "revision" : "3bfd2737b700b9a36565a8c94f4ad2b050a5e574",
+ "version" : "1.0.1"
+ }
+ },
{
"identity" : "processenv",
"kind" : "remoteSourceControl",
@@ -109,30 +172,30 @@
}
},
{
- "identity" : "sparkle",
+ "identity" : "sourcekitten",
"kind" : "remoteSourceControl",
- "location" : "https://github.com/sparkle-project/Sparkle",
+ "location" : "https://github.com/jpsim/SourceKitten",
"state" : {
- "revision" : "87e4fcbac39912f9cdb9a9acf205cad60e1ca3bc",
- "version" : "2.4.2"
+ "revision" : "eb6656ed26bdef967ad8d07c27e2eab34dc582f2",
+ "version" : "0.37.0"
}
},
{
- "identity" : "sttextkitplus",
+ "identity" : "sparkle",
"kind" : "remoteSourceControl",
- "location" : "https://github.com/krzyzanowskim/STTextKitPlus",
+ "location" : "https://github.com/sparkle-project/Sparkle",
"state" : {
- "revision" : "a57a2081e364c71b11e521ed8800481e8da300ac",
- "version" : "0.1.0"
+ "revision" : "0ca3004e98712ea2b39dd881d28448630cce1c99",
+ "version" : "2.7.0"
}
},
{
- "identity" : "sttextview",
+ "identity" : "spectre",
"kind" : "remoteSourceControl",
- "location" : "https://github.com/krzyzanowskim/STTextView",
+ "location" : "https://github.com/kylef/Spectre.git",
"state" : {
- "revision" : "e9e54718b882115db69ec1e17ac1bec844906cd9",
- "version" : "0.9.0"
+ "revision" : "26cc5e9ae0947092c7139ef7ba612e34646086c7",
+ "version" : "0.10.1"
}
},
{
@@ -149,8 +212,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-async-algorithms",
"state" : {
- "revision" : "9cfed92b026c524674ed869a4ff2dcfdeedf8a2a",
- "version" : "0.1.0"
+ "revision" : "042e1c4d9d19748c9c228f8d4ebc97bb1e339b0b",
+ "version" : "1.0.4"
}
},
{
@@ -158,8 +221,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-case-paths",
"state" : {
- "revision" : "fc45e7b2cfece9dd80b5a45e6469ffe67fe67984",
- "version" : "0.14.1"
+ "revision" : "41b89b8b68d8c56c622dbb7132258f1a3e638b25",
+ "version" : "1.7.0"
}
},
{
@@ -167,8 +230,17 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-clocks",
"state" : {
- "revision" : "f9acfa1a45f4483fe0f2c434a74e6f68f865d12d",
- "version" : "0.3.0"
+ "revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e",
+ "version" : "1.0.6"
+ }
+ },
+ {
+ "identity" : "swift-cmark",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/swiftlang/swift-cmark",
+ "state" : {
+ "revision" : "b022b08312decdc46585e0b3440d97f6f22ef703",
+ "version" : "0.6.0"
}
},
{
@@ -176,8 +248,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections",
"state" : {
- "revision" : "937e904258d22af6e447a0b72c0bc67583ef64a2",
- "version" : "1.0.4"
+ "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7",
+ "version" : "1.1.4"
}
},
{
@@ -185,8 +257,17 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-composable-architecture",
"state" : {
- "revision" : "89f80fe2400d21a853abc9556a060a2fa50eb2cb",
- "version" : "0.55.0"
+ "revision" : "69247baf7be2fd6f5820192caef0082d01849cd0",
+ "version" : "1.16.1"
+ }
+ },
+ {
+ "identity" : "swift-concurrency-extras",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-concurrency-extras",
+ "state" : {
+ "revision" : "82a4ae7170d98d8538ec77238b7eb8e7199ef2e8",
+ "version" : "1.3.1"
}
},
{
@@ -194,8 +275,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-custom-dump",
"state" : {
- "revision" : "3a35f7892e7cf6ba28a78cd46a703c0be4e0c6dc",
- "version" : "0.11.0"
+ "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1",
+ "version" : "1.3.3"
}
},
{
@@ -203,8 +284,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-dependencies",
"state" : {
- "revision" : "de1a984a71e51f6e488e98ce3652035563eb8acb",
- "version" : "0.5.1"
+ "revision" : "4c90d6b2b9bf0911af87b103bb40f41771891596",
+ "version" : "1.9.2"
}
},
{
@@ -212,8 +293,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-identified-collections",
"state" : {
- "revision" : "d01446a78fb768adc9a78cbb6df07767c8ccfc29",
- "version" : "0.8.0"
+ "revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597",
+ "version" : "1.1.1"
}
},
{
@@ -221,8 +302,17 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/gonzalezreal/swift-markdown-ui",
"state" : {
- "revision" : "12b351a75201a8124c2f2e1f9fc6ef5cd812c0b9",
- "version" : "2.1.0"
+ "revision" : "5f613358148239d0292c0cef674a3c2314737f9e",
+ "version" : "2.4.1"
+ }
+ },
+ {
+ "identity" : "swift-navigation",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-navigation",
+ "state" : {
+ "revision" : "db6bc9dbfed001f21e6728fd36413d9342c235b4",
+ "version" : "2.3.0"
}
},
{
@@ -230,8 +320,17 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-parsing",
"state" : {
- "revision" : "27c941bbd22a4bbc53005a15a0440443fd892f70",
- "version" : "0.12.1"
+ "revision" : "3432cb81164dd3d69a75d0d63205be5fbae2c34b",
+ "version" : "0.14.1"
+ }
+ },
+ {
+ "identity" : "swift-perception",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-perception",
+ "state" : {
+ "revision" : "d924c62a70fca5f43872f286dbd7cef0957f1c01",
+ "version" : "1.6.0"
}
},
{
@@ -239,8 +338,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-syntax.git",
"state" : {
- "revision" : "6ad4ea24b01559dde0773e3d091f1b9e36175036",
- "version" : "509.0.2"
+ "revision" : "0687f71944021d616d34d922343dcef086855920",
+ "version" : "600.0.1"
}
},
{
@@ -252,6 +351,15 @@
"version" : "2.6.1"
}
},
+ {
+ "identity" : "swiftterm",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/migueldeicaza/SwiftTerm",
+ "state" : {
+ "revision" : "e2b431dbf73f775fb4807a33e4572ffd3dc6933a",
+ "version" : "1.2.5"
+ }
+ },
{
"identity" : "swifttreesitter",
"kind" : "remoteSourceControl",
@@ -262,12 +370,21 @@
}
},
{
- "identity" : "swiftui-navigation",
+ "identity" : "swiftui-introspect",
"kind" : "remoteSourceControl",
- "location" : "https://github.com/pointfreeco/swiftui-navigation",
+ "location" : "https://github.com/siteline/swiftui-introspect",
"state" : {
- "revision" : "2aa885e719087ee19df251c08a5980ad3e787f12",
- "version" : "0.8.0"
+ "revision" : "807f73ce09a9b9723f12385e592b4e0aaebd3336",
+ "version" : "1.3.0"
+ }
+ },
+ {
+ "identity" : "swxmlhash",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/drmohundro/SWXMLHash.git",
+ "state" : {
+ "revision" : "a853604c9e9a83ad9954c7e3d2a565273982471f",
+ "version" : "7.0.2"
}
},
{
@@ -297,13 +414,31 @@
"version" : "0.19.3"
}
},
+ {
+ "identity" : "xcodeproj",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/tuist/XcodeProj.git",
+ "state" : {
+ "revision" : "b1caa062d4aaab3e3d2bed5fe0ac5f8ce9bf84f4",
+ "version" : "8.27.7"
+ }
+ },
{
"identity" : "xctest-dynamic-overlay",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/xctest-dynamic-overlay",
"state" : {
- "revision" : "50843cbb8551db836adec2290bb4bc6bac5c1865",
- "version" : "0.9.0"
+ "revision" : "39de59b2d47f7ef3ca88a039dff3084688fe27f4",
+ "version" : "1.5.2"
+ }
+ },
+ {
+ "identity" : "yams",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/jpsim/Yams.git",
+ "state" : {
+ "revision" : "b4b8042411dc7bbb696300a34a4bf3ba1b7ad19b",
+ "version" : "5.3.1"
}
}
],
diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon.png
deleted file mode 100644
index 291eaac7..00000000
Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon.png and /dev/null differ
diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@128w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@128w.png
deleted file mode 100644
index 160db273..00000000
Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@128w.png and /dev/null differ
diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@16w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@16w.png
deleted file mode 100644
index 4fcd6278..00000000
Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@16w.png and /dev/null differ
diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w 1.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w 1.png
deleted file mode 100644
index e31a8d3b..00000000
Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w 1.png and /dev/null differ
diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w.png
deleted file mode 100644
index e31a8d3b..00000000
Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w.png and /dev/null differ
diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w 1.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w 1.png
deleted file mode 100644
index ec264755..00000000
Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w 1.png and /dev/null differ
diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w.png
deleted file mode 100644
index ec264755..00000000
Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w.png and /dev/null differ
diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w 1.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w 1.png
deleted file mode 100644
index 4b760bc1..00000000
Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w 1.png and /dev/null differ
diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w.png
deleted file mode 100644
index 4b760bc1..00000000
Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w.png and /dev/null differ
diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@64w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@64w.png
deleted file mode 100644
index 8d777985..00000000
Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@64w.png and /dev/null differ
diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/Contents.json b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/Contents.json
index 56acb569..457c1fbf 100644
--- a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/Contents.json
+++ b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -1,61 +1,61 @@
{
"images" : [
{
- "filename" : "1024 x 1024 your icon@16w.png",
+ "filename" : "app-icon@16w.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
- "filename" : "1024 x 1024 your icon@32w 1.png",
+ "filename" : "app-icon@32w.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
- "filename" : "1024 x 1024 your icon@32w.png",
+ "filename" : "app-icon@32w.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
- "filename" : "1024 x 1024 your icon@64w.png",
+ "filename" : "app-icon@64w.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
- "filename" : "1024 x 1024 your icon@128w.png",
+ "filename" : "app-icon@128w.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
- "filename" : "1024 x 1024 your icon@256w 1.png",
+ "filename" : "app-icon@256w.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
- "filename" : "1024 x 1024 your icon@256w.png",
+ "filename" : "app-icon@256w.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
- "filename" : "1024 x 1024 your icon@512w 1.png",
+ "filename" : "app-icon@512w.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
- "filename" : "1024 x 1024 your icon@512w.png",
+ "filename" : "app-icon@512w.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
- "filename" : "1024 x 1024 your icon.png",
+ "filename" : "app-icon.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon.png
new file mode 100644
index 00000000..f7d77720
Binary files /dev/null and b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon.png differ
diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@128w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@128w.png
new file mode 100644
index 00000000..da0bb247
Binary files /dev/null and b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@128w.png differ
diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@16w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@16w.png
new file mode 100644
index 00000000..4f3fcc40
Binary files /dev/null and b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@16w.png differ
diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@256w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@256w.png
new file mode 100644
index 00000000..1f70976c
Binary files /dev/null and b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@256w.png differ
diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@32w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@32w.png
new file mode 100644
index 00000000..44400214
Binary files /dev/null and b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@32w.png differ
diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@512w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@512w.png
new file mode 100644
index 00000000..78d81e50
Binary files /dev/null and b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@512w.png differ
diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@64w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@64w.png
new file mode 100644
index 00000000..a6aae457
Binary files /dev/null and b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@64w.png differ
diff --git a/Copilot-for-Xcode-Info.plist b/Copilot-for-Xcode-Info.plist
index 07a19a85..9f9fdd6e 100644
--- a/Copilot-for-Xcode-Info.plist
+++ b/Copilot-for-Xcode-Info.plist
@@ -12,6 +12,11 @@
$(EXTENSION_BUNDLE_NAME)
HOST_APP_NAME
$(HOST_APP_NAME)
+ NSAppTransportSecurity
+
+ NSAllowsArbitraryLoads
+
+
SUEnableJavaScript
YES
SUFeedURL
diff --git a/Core/Package.swift b/Core/Package.swift
index 283e09b2..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",
@@ -37,14 +37,16 @@ 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.1.0"),
- .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.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"),
.package(url: "https://github.com/pointfreeco/swift-parsing", from: "0.12.1"),
.package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.0.0"),
.package(
url: "https://github.com/pointfreeco/swift-composable-architecture",
- exact: "1.10.4"
+ exact: "1.16.1"
),
// quick hack to support custom UserDefaults
// https://github.com/sindresorhus/KeyboardShortcuts
@@ -63,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(
@@ -92,10 +94,12 @@ 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"),
.product(name: "KeyboardShortcuts", package: "KeyboardShortcuts"),
+ .product(name: "CustomCommandTemplateProcessor", package: "Tool"),
].pro([
"ProService",
])
@@ -124,6 +128,7 @@ let package = Package(
.product(name: "Toast", package: "Tool"),
.product(name: "SharedUIComponents", package: "Tool"),
.product(name: "SuggestionBasic", package: "Tool"),
+ .product(name: "WebSearchService", package: "Tool"),
.product(name: "MarkdownUI", package: "swift-markdown-ui"),
.product(name: "OpenAIService", package: "Tool"),
.product(name: "Preferences", package: "Tool"),
@@ -142,7 +147,7 @@ let package = Package(
.product(name: "UserDefaultsObserver", package: "Tool"),
.product(name: "Preferences", package: "Tool"),
.product(name: "SuggestionBasic", package: "Tool"),
- .product(name: "SuggestionProvider", package: "Tool")
+ .product(name: "SuggestionProvider", package: "Tool"),
].pro([
"ProExtension",
])
@@ -170,29 +175,27 @@ let package = Package(
.target(
name: "ChatService",
dependencies: [
- "ChatPlugin",
-
- // plugins
- "MathChatPlugin",
- "SearchChatPlugin",
- "ShortcutChatPlugin",
+ "LegacyChatPlugin",
// context collectors
"WebChatContextCollector",
"SystemInfoChatContextCollector",
.product(name: "ChatContextCollector", package: "Tool"),
+ .product(name: "PromptToCode", package: "Tool"),
.product(name: "AppMonitoring", package: "Tool"),
- .product(name: "Parsing", package: "swift-parsing"),
.product(name: "OpenAIService", package: "Tool"),
.product(name: "Preferences", package: "Tool"),
+ .product(name: "CustomCommandTemplateProcessor", package: "Tool"),
+ .product(name: "ChatPlugins", package: "ChatPlugins"),
+ .product(name: "Parsing", package: "swift-parsing"),
].pro([
"ProService",
])
),
.testTarget(name: "ChatServiceTests", dependencies: ["ChatService"]),
.target(
- name: "ChatPlugin",
+ name: "LegacyChatPlugin",
dependencies: [
.product(name: "AppMonitoring", package: "Tool"),
.product(name: "OpenAIService", package: "Tool"),
@@ -271,39 +274,6 @@ let package = Package(
])
),
- // MARK: - Chat Plugins
-
- .target(
- name: "MathChatPlugin",
- dependencies: [
- "ChatPlugin",
- .product(name: "OpenAIService", package: "Tool"),
- .product(name: "LangChain", package: "Tool"),
- ],
- path: "Sources/ChatPlugins/MathChatPlugin"
- ),
-
- .target(
- name: "SearchChatPlugin",
- dependencies: [
- "ChatPlugin",
- .product(name: "OpenAIService", package: "Tool"),
- .product(name: "LangChain", package: "Tool"),
- .product(name: "ExternalServices", package: "Tool"),
- ],
- path: "Sources/ChatPlugins/SearchChatPlugin"
- ),
-
- .target(
- name: "ShortcutChatPlugin",
- dependencies: [
- "ChatPlugin",
- .product(name: "Parsing", package: "swift-parsing"),
- .product(name: "Terminal", package: "Tool"),
- ],
- path: "Sources/ChatPlugins/ShortcutChatPlugin"
- ),
-
// MAKR: - Chat Context Collector
.target(
@@ -326,7 +296,7 @@ let package = Package(
],
path: "Sources/ChatContextCollectors/SystemInfoChatContextCollector"
),
-
+
// MARK: Key Binding
.target(
@@ -346,7 +316,7 @@ let package = Package(
name: "KeyBindingManagerTests",
dependencies: ["KeyBindingManager"]
),
-
+
// MARK: Theming
.target(
@@ -357,7 +327,6 @@ let package = Package(
.product(name: "Highlightr", package: "Highlightr"),
]
),
-
]
)
@@ -368,12 +337,20 @@ extension [Target.Dependency] {
}
return self
}
+
+ func proCore(_ targetNames: [String]) -> [Target.Dependency] {
+ if isProIncluded {
+ return self + targetNames
+ .map { Target.Dependency.product(name: $0, package: "ProCore") }
+ }
+ return self
+ }
}
extension [Package.Dependency] {
var pro: [Package.Dependency] {
if isProIncluded {
- return self + [.package(path: "../../Pro")]
+ return self + [.package(path: "../../Pro"), .package(path: "../../Pro/ProCore")]
}
return self
}
@@ -393,3 +370,4 @@ var isProIncluded: Bool {
return isProIncluded()
}
+
diff --git a/Core/Sources/ChatContextCollectors/WebChatContextCollector/QueryWebsiteFunction.swift b/Core/Sources/ChatContextCollectors/WebChatContextCollector/QueryWebsiteFunction.swift
index 6d5de208..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 {
@@ -55,16 +59,23 @@ struct QueryWebsiteFunction: ChatGPTFunction {
reportProgress: @escaping (String) async -> Void
) async throws -> Result {
do {
- let embedding = OpenAIEmbedding(configuration: UserPreferenceEmbeddingConfiguration())
+ let configuration = UserPreferenceEmbeddingConfiguration()
+ let embedding = OpenAIEmbedding(configuration: configuration)
+ let dimensions = configuration.dimensions
+ let modelName = configuration.model?.name ?? "model"
let result = try await withThrowingTaskGroup(of: String.self) { group in
for urlString in arguments.urls {
+ let storeIdentifier = "\(urlString)-\(modelName)-\(dimensions)"
guard let url = URL(string: urlString) else { continue }
group.addTask {
// 1. grab the website content
await reportProgress("Loading \(url)..")
- if let database = await TemporaryUSearch.view(identifier: urlString) {
+ if let database = await TemporaryUSearch.view(
+ identifier: storeIdentifier,
+ dimensions: dimensions
+ ) {
await reportProgress("Getting relevant information..")
let qa = QAInformationRetrievalChain(
vectorStore: database,
@@ -77,14 +88,17 @@ struct QueryWebsiteFunction: ChatGPTFunction {
await reportProgress("Processing \(url)..")
// 2. split the content
let splitter = RecursiveCharacterTextSplitter(
- chunkSize: 1000,
+ chunkSize: 1500,
chunkOverlap: 100
)
let splitDocuments = try await splitter.transformDocuments(documents)
// 3. embedding and store in db
await reportProgress("Embedding \(url)..")
let embeddedDocuments = try await embedding.embed(documents: splitDocuments)
- let database = TemporaryUSearch(identifier: urlString)
+ let database = TemporaryUSearch(
+ identifier: storeIdentifier,
+ dimensions: dimensions
+ )
try await database.set(embeddedDocuments)
// 4. generate answer
await reportProgress("Getting relevant information..")
diff --git a/Core/Sources/ChatContextCollectors/WebChatContextCollector/SearchFunction.swift b/Core/Sources/ChatContextCollectors/WebChatContextCollector/SearchFunction.swift
index 56719f5c..60a5504e 100644
--- a/Core/Sources/ChatContextCollectors/WebChatContextCollector/SearchFunction.swift
+++ b/Core/Sources/ChatContextCollectors/WebChatContextCollector/SearchFunction.swift
@@ -1,8 +1,8 @@
-import BingSearchService
import ChatBasic
import Foundation
import OpenAIService
import Preferences
+import WebSearchService
struct SearchFunction: ChatGPTFunction {
static let dateFormatter = {
@@ -17,17 +17,21 @@ struct SearchFunction: ChatGPTFunction {
}
struct Result: ChatGPTFunctionResult {
- var result: BingSearchResult
+ var result: WebSearchResult
var botReadableContent: String {
- result.webPages.value.enumerated().map {
+ result.webPages.enumerated().map {
let (index, page) = $0
return """
- \(index + 1). \(page.name) \(page.url)
+ \(index + 1). \(page.title) \(page.urlString)
\(page.snippet)
"""
}.joined(separator: "\n")
}
+
+ var userReadableContent: ChatGPTFunctionResultUserReadableContent {
+ .text(botReadableContent)
+ }
}
let maxTokens: Int
@@ -72,22 +76,15 @@ struct SearchFunction: ChatGPTFunction {
await reportProgress("Searching \(arguments.query)")
do {
- let bingSearch = BingSearchService(
- subscriptionKey: UserDefaults.shared.value(for: \.bingSearchSubscriptionKey),
- searchURL: UserDefaults.shared.value(for: \.bingSearchEndpoint)
- )
+ let search = WebSearchService(provider: .userPreferred)
- let result = try await bingSearch.search(
- query: arguments.query,
- numberOfResult: maxTokens > 5000 ? 5 : 3,
- freshness: arguments.freshness
- )
+ let result = try await search.search(query: arguments.query)
await reportProgress("""
Finish searching \(arguments.query)
\(
- result.webPages.value
- .map { "- [\($0.name)](\($0.url))" }
+ result.webPages
+ .map { "- [\($0.title)](\($0.urlString))" }
.joined(separator: "\n")
)
""")
diff --git a/Core/Sources/ChatGPTChatTab/Chat.swift b/Core/Sources/ChatGPTChatTab/Chat.swift
index 564f2489..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,12 +125,10 @@ struct Chat {
case sendMessage(UUID)
}
- @Dependency(\.openURL) var openURL
-
var body: some ReducerOf {
BindingReducer()
- Scope(state: \.chatMenu, action: /Action.chatMenu) {
+ Scope(state: \.chatMenu, action: \.chatMenu) {
ChatMenu(service: service)
}
@@ -206,15 +205,15 @@ struct Chat {
"/bin/bash",
arguments: [
"-c",
- "xed -l \(reference.startLine ?? 0) \"\(reference.uri)\"",
+ "xed -l \(reference.startLine ?? 0) ${TARGET_FILE}",
],
- environment: [:]
+ environment: ["TARGET_FILE": reference.uri]
)
} catch {
print(error)
}
} else if let url = URL(string: reference.uri), url.scheme != nil {
- await openURL(url)
+ NSWorkspace.shared.open(url)
}
}
@@ -522,6 +521,8 @@ private func convertReference(
return kind
case .text:
return reference.content
+ case .error:
+ return reference.content
}
}(),
uri: {
@@ -536,6 +537,8 @@ private func convertReference(
return ""
case .text:
return ""
+ case .error:
+ return ""
}
}(),
startLine: {
diff --git a/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift b/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift
index a791db29..9114a5dd 100644
--- a/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift
+++ b/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift
@@ -72,7 +72,7 @@ struct ChatContextMenu: View {
var chatModel: some View {
let allModels = chatModels + [.init(
id: "com.github.copilot",
- name: "GitHub Copilot (poc)",
+ name: "GitHub Copilot Language Server",
format: .openAI,
info: .init()
)]
diff --git a/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift b/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift
index 3b3ddc46..ad2c6887 100644
--- a/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift
+++ b/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift
@@ -102,11 +102,11 @@ public class ChatGPTChatTab: ChatTab {
return nil
}
- return [Builder(title: "New Chat", customCommand: nil)] + customCommands
+ return [Builder(title: "Legacy Chat", customCommand: nil)] + customCommands
}
-
+
public static func defaultBuilder() -> ChatTabBuilder {
- Builder(title: "New Chat", customCommand: nil)
+ Builder(title: "Legacy Chat", customCommand: nil)
}
@MainActor
@@ -134,7 +134,7 @@ public class ChatGPTChatTab: ChatTab {
}
}.store(in: &cancellable)
- do {
+ Task { @MainActor in
var lastTrigger = -1
observer.observe { [weak self] in
guard let self else { return }
@@ -147,7 +147,7 @@ public class ChatGPTChatTab: ChatTab {
}
}
- do {
+ Task { @MainActor in
var lastTitle = ""
observer.observe { [weak self] in
guard let self else { return }
@@ -160,17 +160,29 @@ public class ChatGPTChatTab: ChatTab {
}
}
- observer.observe { [weak self] in
- guard let self else { return }
- _ = chat.history
- _ = chat.title
- _ = chat.isReceivingMessage
- Task {
- await self.updateContentDebounce.debounce { @MainActor [weak self] in
- self?.chatTabStore.send(.tabContentUpdated)
+ Task { @MainActor in
+ observer.observe { [weak self] in
+ guard let self else { return }
+ _ = chat.history
+ _ = chat.title
+ _ = chat.isReceivingMessage
+ Task {
+ await self.updateContentDebounce.debounce { @MainActor [weak self] in
+ self?.chatTabStore.send(.tabContentUpdated)
+ }
}
}
}
}
+
+ public func handleCustomCommand(_ customCommand: CustomCommand) -> Bool {
+ Task {
+ if service.isReceivingMessage {
+ await service.stopReceivingMessage()
+ }
+ try? await service.handleCustomCommand(customCommand)
+ }
+ return true
+ }
}
diff --git a/Core/Sources/ChatGPTChatTab/CodeBlockHighlighter.swift b/Core/Sources/ChatGPTChatTab/CodeBlockHighlighter.swift
index cfbde1c2..0e506b96 100644
--- a/Core/Sources/ChatGPTChatTab/CodeBlockHighlighter.swift
+++ b/Core/Sources/ChatGPTChatTab/CodeBlockHighlighter.swift
@@ -41,7 +41,7 @@ struct AsyncCodeBlockView: View {
let content = view.content
let language = view.fenceInfo ?? ""
let brightMode = view.colorScheme != .dark
- let font = view.font
+ let font = CodeHighlighting.SendableFont(font: view.font)
highlightTask = Task {
let string = await withUnsafeContinuation { continuation in
Self.queue.async {
@@ -50,7 +50,7 @@ struct AsyncCodeBlockView: View {
language: language,
scenario: "chat",
brightMode: brightMode,
- font: font
+ font:font
)
continuation.resume(returning: AttributedString(content))
}
diff --git a/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift b/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift
index 09bcd8e8..bcd9a455 100644
--- a/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift
+++ b/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift
@@ -172,6 +172,8 @@ struct ReferenceIcon: View {
Color.gray
case .other:
Color.gray
+ case .error:
+ Color.red
}
}())
.frame(width: 22, height: 22)
@@ -211,6 +213,8 @@ struct ReferenceIcon: View {
Text("Ot")
case .textFile:
Text("Tx")
+ case .error:
+ Text("Er")
}
}
.font(.system(size: 12).monospaced())
diff --git a/Core/Sources/ChatGPTChatTab/Views/Instructions.swift b/Core/Sources/ChatGPTChatTab/Views/Instructions.swift
index e2f9b2f0..dba6bfbf 100644
--- a/Core/Sources/ChatGPTChatTab/Views/Instructions.swift
+++ b/Core/Sources/ChatGPTChatTab/Views/Instructions.swift
@@ -15,11 +15,8 @@ struct Instruction: View {
| Plugin Name | Description |
| --- | --- |
- | `/run` | Runs a command under the project root |
- | `/math` | Solves a math problem in natural language |
- | `/search` | Searches on Bing and summarizes the results |
+ | `/shell` | Runs a command under the project root |
| `/shortcut(name)` | Runs a shortcut from the Shortcuts.app, with the previous message as input |
- | `/shortcutInput(name)` | Runs a shortcut and uses its result as a new message |
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/ChatPlugin/AITerminalChatPlugin.swift b/Core/Sources/ChatPlugin/AITerminalChatPlugin.swift
deleted file mode 100644
index d00990f4..00000000
--- a/Core/Sources/ChatPlugin/AITerminalChatPlugin.swift
+++ /dev/null
@@ -1,196 +0,0 @@
-import Foundation
-import OpenAIService
-import Terminal
-
-public actor AITerminalChatPlugin: ChatPlugin {
- public static var command: String { "airun" }
- public nonisolated var name: String { "AI Terminal" }
-
- let chatGPTService: any LegacyChatGPTServiceType
- var terminal: TerminalType = Terminal()
- var isCancelled = false
- weak var delegate: ChatPluginDelegate?
- var isStarted = false
- var command: String?
-
- public init(inside chatGPTService: any LegacyChatGPTServiceType, delegate: ChatPluginDelegate) {
- self.chatGPTService = chatGPTService
- self.delegate = delegate
- }
-
- public func send(content: String, originalMessage: String) async {
- if !isStarted {
- isStarted = true
- delegate?.pluginDidStart(self)
- }
-
- do {
- if let command {
- await chatGPTService.memory.mutateHistory { history in
- history.append(.init(role: .user, content: content))
- }
- delegate?.pluginDidStartResponding(self)
- if isCancelled { return }
- switch try await checkConfirmation(content: content) {
- case .confirmation:
- delegate?.pluginDidEndResponding(self)
- delegate?.pluginDidEnd(self)
- delegate?.shouldStartAnotherPlugin(
- TerminalChatPlugin.self,
- withContent: command
- )
- case .cancellation:
- delegate?.pluginDidEndResponding(self)
- delegate?.pluginDidEnd(self)
- await chatGPTService.memory.mutateHistory { history in
- history.append(.init(role: .assistant, content: "Cancelled"))
- }
- case .modification:
- let result = try await modifyCommand(command: command, requirement: content)
- self.command = result
- delegate?.pluginDidEndResponding(self)
- await chatGPTService.memory.mutateHistory { history in
- history.append(.init(role: .assistant, content: """
- Should I run this command? You can instruct me to modify it again.
- ```
- \(result)
- ```
- """))
- }
- case .other:
- delegate?.pluginDidEndResponding(self)
- await chatGPTService.memory.mutateHistory { history in
- history.append(.init(
- role: .assistant,
- content: "Sorry, I don't understand. Do you want me to run it?"
- ))
- }
- }
- } else {
- await chatGPTService.memory.mutateHistory { history in
- history.append(.init(
- role: .user,
- content: originalMessage,
- summary: "Run a command to \(content)")
- )
- }
- delegate?.pluginDidStartResponding(self)
- let result = try await generateCommand(task: content)
- command = result
- if isCancelled { return }
- await chatGPTService.memory.mutateHistory { history in
- history.append(.init(role: .assistant, content: """
- Should I run this command? You can instruct me to modify it.
- ```
- \(result)
- ```
- """))
- }
- delegate?.pluginDidEndResponding(self)
- }
- } catch {
- await chatGPTService.memory.mutateHistory { history in
- history.append(.init(role: .assistant, content: error.localizedDescription))
- }
- delegate?.pluginDidEndResponding(self)
- delegate?.pluginDidEnd(self)
- }
- }
-
- public func cancel() async {
- isCancelled = true
- delegate?.pluginDidEndResponding(self)
- delegate?.pluginDidEnd(self)
- }
-
- public func stopResponding() async {}
-
- func generateCommand(task: String) async throws -> String {
- let p = """
- Available environment variables:
- - $PROJECT_ROOT: the root path of the project
- - $FILE_PATH: the currently editing file
-
- Current directory path is the project root.
-
- Generate a terminal command to solve the given task on macOS. If one command is not enough, you can use && to concatenate multiple commands.
-
- The reply should contains only the command and nothing else.
- """
-
- return extractCodeFromMarkdown(try await askChatGPT(
- systemPrompt: p,
- question: "the task is: \"\(task)\""
- ) ?? "")
- }
-
- func modifyCommand(command: String, requirement: String) async throws -> String {
- let p = """
- Available environment variables:
- - $PROJECT_ROOT: the root path of the project
- - $FILE_PATH: the currently editing file
-
- Current directory path is the project root.
-
- Modify the terminal command `\(
- command
- )` in macOS with the given requirement. If one command is not enough, you can use && to concatenate multiple commands.
-
- The reply should contains only the command and nothing else.
- """
-
- return extractCodeFromMarkdown(try await askChatGPT(
- systemPrompt: p,
- question: "The requirement is: \"\(requirement)\""
- ) ?? "")
- }
-
- func checkConfirmation(content: String) async throws -> Tone {
- let p = """
- Check the tone of the content, reply with only the number representing the tone.
-
- 1: If the given content is a phrase or sentence that considered a confirmation to run a command.
-
- For example: "Yes", "Confirm", "True", "Run it". It can be in any language.
-
- 2: If the given content is a phrase or sentence that considered a cancellation to run a command.
-
- For example: "No", "Cancel", "False", "Don't run it", "Stop". It can be in any language.
-
- 3: If the given content is a modification request.
-
- For example: "Use echo instead", "Remove the argument", "Change to path".
-
- 4: Everything else.
- """
-
- let result = try await askChatGPT(
- systemPrompt: p,
- question: "The content is: \"\(content)\""
- )
- let tone = result.flatMap(Int.init).flatMap(Tone.init(rawValue:)) ?? .other
- return tone
- }
-
- enum Tone: Int {
- case confirmation = 1
- case cancellation = 2
- case modification = 3
- case other = 4
- }
-
- func extractCodeFromMarkdown(_ markdown: String) -> String {
- let codeBlockRegex = try! NSRegularExpression(
- pattern: "```[\n](.*?)[\n]```",
- options: .dotMatchesLineSeparators
- )
- let range = NSRange(markdown.startIndex.. String {
- guard let reply = try await askChatGPT(
- systemPrompt: systemPrompt,
- question: "Question: \(question)",
- temperature: 0
- ) else { return "No answer." }
-
- // parse inside text code block
- let codeBlockRegex = try NSRegularExpression(pattern: "```text\n(.*?)\n```", options: [])
- let codeBlockMatches = codeBlockRegex.matches(
- in: reply,
- options: [],
- range: NSRange(reply.startIndex.. String? {
- let mathExpression = NSExpression(format: expression)
- let value = mathExpression.expressionValue(with: nil, context: nil)
- Logger.service.debug(String(describing: value))
- return (value as? Int).flatMap(String.init)
-}
-
diff --git a/Core/Sources/ChatPlugins/SearchChatPlugin/SearchChatPlugin.swift b/Core/Sources/ChatPlugins/SearchChatPlugin/SearchChatPlugin.swift
deleted file mode 100644
index 1b05168f..00000000
--- a/Core/Sources/ChatPlugins/SearchChatPlugin/SearchChatPlugin.swift
+++ /dev/null
@@ -1,98 +0,0 @@
-import ChatPlugin
-import Foundation
-import OpenAIService
-
-public actor SearchChatPlugin: ChatPlugin {
- public static var command: String { "search" }
- public nonisolated var name: String { "Search" }
-
- let chatGPTService: any LegacyChatGPTServiceType
- var isCancelled = false
- weak var delegate: ChatPluginDelegate?
-
- public init(inside chatGPTService: any LegacyChatGPTServiceType, delegate: ChatPluginDelegate) {
- self.chatGPTService = chatGPTService
- self.delegate = delegate
- }
-
- public func send(content: String, originalMessage: String) async {
- delegate?.pluginDidStart(self)
- delegate?.pluginDidStartResponding(self)
-
- let id = "\(Self.command)-\(UUID().uuidString)"
- var reply = ChatMessage(id: id, role: .assistant, content: "")
-
- await chatGPTService.memory.appendMessage(.init(role: .user, content: originalMessage, summary: content))
-
- do {
- let (eventStream, cancelAgent) = try await search(content)
-
- var actions = [String]()
- var finishedActions = Set()
- var message = ""
-
- for try await event in eventStream {
- guard !isCancelled else {
- await cancelAgent()
- break
- }
- switch event {
- case let .startAction(content):
- actions.append(content)
- case let .endAction(content):
- finishedActions.insert(content)
- case let .answerToken(token):
- message.append(token)
- case let .finishAnswer(answer, links):
- message = """
- \(answer)
-
- \(links.map { "- [\($0.title)](\($0.link))" }.joined(separator: "\n"))
- """
- }
-
- await chatGPTService.memory.mutateHistory { history in
- if history.last?.id == id {
- history.removeLast()
- }
-
- let actionString = actions.map {
- "> \(finishedActions.contains($0) ? "✅" : "🔍") \($0)"
- }.joined(separator: "\n>\n")
-
- if message.isEmpty {
- reply.content = actionString
- } else {
- reply.content = """
- \(actionString)
-
- \(message)
- """
- }
- history.append(reply)
- }
- }
-
- } catch {
- await chatGPTService.memory.mutateHistory { history in
- if history.last?.id == id {
- history.removeLast()
- }
- reply.content = error.localizedDescription
- history.append(reply)
- }
- }
-
- delegate?.pluginDidEndResponding(self)
- delegate?.pluginDidEnd(self)
- }
-
- public func cancel() async {
- isCancelled = true
- }
-
- public func stopResponding() async {
- isCancelled = true
- }
-}
-
diff --git a/Core/Sources/ChatPlugins/SearchChatPlugin/SearchQuery.swift b/Core/Sources/ChatPlugins/SearchChatPlugin/SearchQuery.swift
deleted file mode 100644
index 18df59da..00000000
--- a/Core/Sources/ChatPlugins/SearchChatPlugin/SearchQuery.swift
+++ /dev/null
@@ -1,103 +0,0 @@
-import BingSearchService
-import Foundation
-import LangChain
-import OpenAIService
-
-enum SearchEvent {
- case startAction(String)
- case endAction(String)
- case answerToken(String)
- case finishAnswer(String, [(title: String, link: String)])
-}
-
-func search(_ query: String) async throws
- -> (stream: AsyncThrowingStream, cancel: () async -> Void)
-{
- let bingSearch = BingSearchService(
- subscriptionKey: UserDefaults.shared.value(for: \.bingSearchSubscriptionKey),
- searchURL: UserDefaults.shared.value(for: \.bingSearchEndpoint)
- )
-
- final class LinkStorage {
- var links = [(title: String, link: String)]()
- }
-
- let linkStorage = LinkStorage()
-
- let tools = [
- SimpleAgentTool(
- name: "Search",
- description: "useful for when you need to answer questions about current events. Don't search for the same thing twice",
- run: {
- linkStorage.links = []
- let result = try await bingSearch.search(query: $0, numberOfResult: 5)
- let websites = result.webPages.value
-
- var string = ""
- for (index, website) in websites.enumerated() {
- string.append("[\(index)]:###\(website.snippet)###\n")
- linkStorage.links.append((website.name, website.url))
- }
- return string
- }
- ),
- ]
-
- let chatModel = OpenAIChat(
- configuration: UserPreferenceChatGPTConfiguration().overriding { $0.temperature = 0 },
- stream: true
- )
-
- let agentExecutor = AgentExecutor(
- agent: ChatAgent(
- chatModel: chatModel,
- tools: tools,
- preferredLanguage: UserDefaults.shared.value(for: \.chatGPTLanguage)
- ),
- tools: tools,
- maxIteration: UserDefaults.shared.value(for: \.chatSearchPluginMaxIterations),
- earlyStopHandleType: .generate
- )
-
- return (AsyncThrowingStream { continuation in
- var accumulation: String = ""
- var isGeneratingFinalAnswer = false
-
- let callbackManager = CallbackManager { manager in
- manager.on(CallbackEvents.AgentActionDidStart.self) {
- continuation.yield(.startAction("\($0.toolName): \($0.toolInput)"))
- }
-
- manager.on(CallbackEvents.AgentActionDidEnd.self) {
- continuation.yield(.endAction("\($0.toolName): \($0.toolInput)"))
- }
-
- manager.on(CallbackEvents.LLMDidProduceNewToken.self) {
- if isGeneratingFinalAnswer {
- continuation.yield(.answerToken($0))
- return
- }
- accumulation.append($0)
- if accumulation.hasSuffix("Final Answer: ") {
- isGeneratingFinalAnswer = true
- accumulation = ""
- }
- }
- }
- Task {
- do {
- let finalAnswer = try await agentExecutor.run(
- query,
- callbackManagers: [callbackManager]
- )
- continuation.yield(.finishAnswer(finalAnswer, linkStorage.links))
- continuation.finish()
- } catch {
- continuation.finish(throwing: error)
- }
- }
- }, {
- await agentExecutor.cancel()
- })
-}
-
diff --git a/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutChatPlugin.swift b/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutChatPlugin.swift
deleted file mode 100644
index 23eb75ec..00000000
--- a/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutChatPlugin.swift
+++ /dev/null
@@ -1,124 +0,0 @@
-import ChatPlugin
-import Foundation
-import OpenAIService
-import Parsing
-import Terminal
-
-public actor ShortcutChatPlugin: ChatPlugin {
- public static var command: String { "shortcut" }
- public nonisolated var name: String { "Shortcut" }
-
- let chatGPTService: any LegacyChatGPTServiceType
- var terminal: TerminalType = Terminal()
- var isCancelled = false
- weak var delegate: ChatPluginDelegate?
-
- public init(inside chatGPTService: any LegacyChatGPTServiceType, delegate: ChatPluginDelegate) {
- self.chatGPTService = chatGPTService
- self.delegate = delegate
- }
-
- public func send(content: String, originalMessage: String) async {
- delegate?.pluginDidStart(self)
- delegate?.pluginDidStartResponding(self)
-
- defer {
- delegate?.pluginDidEndResponding(self)
- delegate?.pluginDidEnd(self)
- }
-
- let id = "\(Self.command)-\(UUID().uuidString)"
- var message = ChatMessage(id: id, role: .assistant, content: "")
-
- var content = content[...]
- let firstParenthesisParser = PrefixThrough("(")
- let shortcutNameParser = PrefixUpTo(")")
-
- _ = try? firstParenthesisParser.parse(&content)
- let shortcutName = try? shortcutNameParser.parse(&content)
- _ = try? PrefixThrough(")").parse(&content)
-
- guard let shortcutName, !shortcutName.isEmpty else {
- message.content =
- "Please provide the shortcut name in format: `/\(Self.command)(shortcut name)`."
- await chatGPTService.memory.appendMessage(message)
- return
- }
-
- var input = String(content).trimmingCharacters(in: .whitespacesAndNewlines)
- if input.isEmpty {
- // if no input detected, use the previous message as input
- input = await chatGPTService.memory.history.last?.content ?? ""
- await chatGPTService.memory.appendMessage(.init(role: .user, content: originalMessage))
- } else {
- await chatGPTService.memory.appendMessage(.init(role: .user, content: originalMessage))
- }
-
- do {
- if isCancelled { throw CancellationError() }
-
- let env = ProcessInfo.processInfo.environment
- let shell = env["SHELL"] ?? "/bin/bash"
- let temporaryURL = FileManager.default.temporaryDirectory
- let temporaryInputFileURL = temporaryURL
- .appendingPathComponent("\(id)-input.txt")
- let temporaryOutputFileURL = temporaryURL
- .appendingPathComponent("\(id)-output")
-
- try input.write(to: temporaryInputFileURL, atomically: true, encoding: .utf8)
-
- let command = """
- shortcuts run "\(shortcutName)" \
- -i "\(temporaryInputFileURL.path)" \
- -o "\(temporaryOutputFileURL.path)"
- """
-
- _ = try await terminal.runCommand(
- shell,
- arguments: ["-i", "-l", "-c", command],
- currentDirectoryURL: nil,
- environment: [:]
- )
-
- await Task.yield()
-
- if FileManager.default.fileExists(atPath: temporaryOutputFileURL.path) {
- let data = try Data(contentsOf: temporaryOutputFileURL)
- if let text = String(data: data, encoding: .utf8) {
- message.content = text
- if text.isEmpty {
- message.content = "Finished"
- }
- await chatGPTService.memory.appendMessage(message)
- } else {
- message.content = """
- [View File](\(temporaryOutputFileURL))
- """
- await chatGPTService.memory.appendMessage(message)
- }
-
- return
- }
-
- message.content = "Finished"
- await chatGPTService.memory.appendMessage(message)
- } catch {
- message.content = error.localizedDescription
- if error.localizedDescription.isEmpty {
- message.content = "Error"
- }
- await chatGPTService.memory.appendMessage(message)
- }
- }
-
- public func cancel() async {
- isCancelled = true
- await terminal.terminate()
- }
-
- public func stopResponding() async {
- isCancelled = true
- await terminal.terminate()
- }
-}
-
diff --git a/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutInputChatPlugin.swift b/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutInputChatPlugin.swift
deleted file mode 100644
index 8eab91ff..00000000
--- a/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutInputChatPlugin.swift
+++ /dev/null
@@ -1,126 +0,0 @@
-import ChatPlugin
-import Foundation
-import OpenAIService
-import Parsing
-import Terminal
-
-public actor ShortcutInputChatPlugin: ChatPlugin {
- public static var command: String { "shortcutInput" }
- public nonisolated var name: String { "Shortcut Input" }
-
- let chatGPTService: any LegacyChatGPTServiceType
- var terminal: TerminalType = Terminal()
- var isCancelled = false
- weak var delegate: ChatPluginDelegate?
-
- public init(inside chatGPTService: any LegacyChatGPTServiceType, delegate: ChatPluginDelegate) {
- self.chatGPTService = chatGPTService
- self.delegate = delegate
- }
-
- public func send(content: String, originalMessage: String) async {
- delegate?.pluginDidStart(self)
- delegate?.pluginDidStartResponding(self)
-
- defer {
- delegate?.pluginDidEndResponding(self)
- delegate?.pluginDidEnd(self)
- }
-
- let id = "\(Self.command)-\(UUID().uuidString)"
-
- var content = content[...]
- let firstParenthesisParser = PrefixThrough("(")
- let shortcutNameParser = PrefixUpTo(")")
-
- _ = try? firstParenthesisParser.parse(&content)
- let shortcutName = try? shortcutNameParser.parse(&content)
- _ = try? PrefixThrough(")").parse(&content)
-
- guard let shortcutName, !shortcutName.isEmpty else {
- let id = "\(Self.command)-\(UUID().uuidString)"
- let reply = ChatMessage(
- id: id,
- role: .assistant,
- content: "Please provide the shortcut name in format: `/\(Self.command)(shortcut name)`."
- )
- await chatGPTService.memory.appendMessage(reply)
- return
- }
-
- var input = String(content).trimmingCharacters(in: .whitespacesAndNewlines)
- if input.isEmpty {
- // if no input detected, use the previous message as input
- input = await chatGPTService.memory.history.last?.content ?? ""
- }
-
- do {
- if isCancelled { throw CancellationError() }
-
- let env = ProcessInfo.processInfo.environment
- let shell = env["SHELL"] ?? "/bin/bash"
- let temporaryURL = FileManager.default.temporaryDirectory
- let temporaryInputFileURL = temporaryURL
- .appendingPathComponent("\(id)-input.txt")
- let temporaryOutputFileURL = temporaryURL
- .appendingPathComponent("\(id)-output")
-
- try input.write(to: temporaryInputFileURL, atomically: true, encoding: .utf8)
-
- let command = """
- shortcuts run "\(shortcutName)" \
- -i "\(temporaryInputFileURL.path)" \
- -o "\(temporaryOutputFileURL.path)"
- """
-
- _ = try await terminal.runCommand(
- shell,
- arguments: ["-i", "-l", "-c", command],
- currentDirectoryURL: nil,
- environment: [:]
- )
-
- await Task.yield()
-
- if FileManager.default.fileExists(atPath: temporaryOutputFileURL.path) {
- let data = try Data(contentsOf: temporaryOutputFileURL)
- if let text = String(data: data, encoding: .utf8) {
- if text.isEmpty { return }
- let stream = try await chatGPTService.send(content: text, summary: nil)
- do {
- for try await _ in stream {}
- } catch {}
- } else {
- let text = """
- [View File](\(temporaryOutputFileURL))
- """
- let stream = try await chatGPTService.send(content: text, summary: nil)
- do {
- for try await _ in stream {}
- } catch {}
- }
-
- return
- }
- } catch {
- let id = "\(Self.command)-\(UUID().uuidString)"
- let reply = ChatMessage(
- id: id,
- role: .assistant,
- content: error.localizedDescription
- )
- await chatGPTService.memory.appendMessage(reply)
- }
- }
-
- public func cancel() async {
- isCancelled = true
- await terminal.terminate()
- }
-
- public func stopResponding() async {
- isCancelled = true
- await terminal.terminate()
- }
-}
-
diff --git a/Core/Sources/ChatService/AllPlugins.swift b/Core/Sources/ChatService/AllPlugins.swift
index 108200a2..3f0b9de7 100644
--- a/Core/Sources/ChatService/AllPlugins.swift
+++ b/Core/Sources/ChatService/AllPlugins.swift
@@ -1,14 +1,144 @@
-import ChatPlugin
-import MathChatPlugin
-import SearchChatPlugin
+import ChatBasic
+import Foundation
+import OpenAIService
import ShortcutChatPlugin
+import TerminalChatPlugin
-let allPlugins: [ChatPlugin.Type] = [
- TerminalChatPlugin.self,
- AITerminalChatPlugin.self,
- MathChatPlugin.self,
- SearchChatPlugin.self,
- ShortcutChatPlugin.self,
- ShortcutInputChatPlugin.self,
+let allPlugins: [LegacyChatPlugin.Type] = [
+ LegacyChatPluginWrapper.self,
+ LegacyChatPluginWrapper.self,
]
+protocol LegacyChatPlugin: AnyObject {
+ static var command: String { get }
+ var name: String { get }
+
+ init(inside chatGPTService: any LegacyChatGPTServiceType, delegate: LegacyChatPluginDelegate)
+ func send(content: String, originalMessage: String) async
+ func cancel() async
+ func stopResponding() async
+}
+
+protocol LegacyChatPluginDelegate: AnyObject {
+ func pluginDidStart(_ plugin: LegacyChatPlugin)
+ func pluginDidEnd(_ plugin: LegacyChatPlugin)
+ func pluginDidStartResponding(_ plugin: LegacyChatPlugin)
+ func pluginDidEndResponding(_ plugin: LegacyChatPlugin)
+ func shouldStartAnotherPlugin(_ type: LegacyChatPlugin.Type, withContent: String)
+}
+
+final class LegacyChatPluginWrapper: LegacyChatPlugin {
+ static var command: String { Plugin.command }
+ var name: String { Plugin.name }
+
+ let chatGPTService: any LegacyChatGPTServiceType
+ weak var delegate: LegacyChatPluginDelegate?
+ var isCancelled = false
+
+ required init(
+ inside chatGPTService: any LegacyChatGPTServiceType,
+ delegate: any LegacyChatPluginDelegate
+ ) {
+ self.chatGPTService = chatGPTService
+ self.delegate = delegate
+ }
+
+ func send(content: String, originalMessage: String) async {
+ delegate?.pluginDidStart(self)
+ delegate?.pluginDidStartResponding(self)
+
+ let id = "\(Self.command)-\(UUID().uuidString)"
+ var reply = ChatMessage(id: id, role: .assistant, content: "")
+
+ await chatGPTService.memory.mutateHistory { history in
+ history.append(.init(role: .user, content: originalMessage))
+ }
+
+ let plugin = Plugin()
+
+ let stream = await plugin.sendForComplicatedResponse(.init(
+ text: content,
+ arguments: [],
+ history: chatGPTService.memory.history
+ ))
+
+ do {
+ var actions = [(id: String, name: String)]()
+ var actionResults = [String: String]()
+ var message = ""
+
+ for try await response in stream {
+ guard !isCancelled else { break }
+ if Task.isCancelled { break }
+
+ switch response {
+ case .status:
+ break
+ case let .content(content):
+ switch content {
+ case let .text(token):
+ message.append(token)
+ }
+ case .attachments:
+ break
+ case let .startAction(id, task):
+ actions.append((id: id, name: task))
+ case let .finishAction(id, result):
+ actionResults[id] = switch result {
+ case let .failure(error):
+ error
+ case let .success(result):
+ result
+ }
+ case .references:
+ break
+ case .startNewMessage:
+ break
+ case .reasoning:
+ break
+ }
+
+ await chatGPTService.memory.mutateHistory { history in
+ if history.last?.id == id {
+ history.removeLast()
+ }
+
+ let actionString = actions.map {
+ "> \($0.name): \(actionResults[$0.id] ?? "...")"
+ }.joined(separator: "\n>\n")
+
+ if message.isEmpty {
+ reply.content = actionString
+ } else {
+ reply.content = """
+ \(actionString)
+
+ \(message)
+ """
+ }
+ history.append(reply)
+ }
+ }
+ } catch {
+ await chatGPTService.memory.mutateHistory { history in
+ if history.last?.id == id {
+ history.removeLast()
+ }
+ reply.content = error.localizedDescription
+ history.append(reply)
+ }
+ }
+
+ delegate?.pluginDidEndResponding(self)
+ delegate?.pluginDidEnd(self)
+ }
+
+ func cancel() async {
+ isCancelled = true
+ }
+
+ func stopResponding() async {
+ isCancelled = true
+ }
+}
+
diff --git a/Core/Sources/ChatService/ChatPluginController.swift b/Core/Sources/ChatService/ChatPluginController.swift
index 82a35662..c1a6d973 100644
--- a/Core/Sources/ChatService/ChatPluginController.swift
+++ b/Core/Sources/ChatService/ChatPluginController.swift
@@ -1,24 +1,27 @@
-import ChatPlugin
import Combine
import Foundation
+import LegacyChatPlugin
import OpenAIService
final class ChatPluginController {
let chatGPTService: any LegacyChatGPTServiceType
- let plugins: [String: ChatPlugin.Type]
- var runningPlugin: ChatPlugin?
+ let plugins: [String: LegacyChatPlugin.Type]
+ var runningPlugin: LegacyChatPlugin?
weak var chatService: ChatService?
-
- init(chatGPTService: any LegacyChatGPTServiceType, plugins: [ChatPlugin.Type]) {
+
+ init(chatGPTService: any LegacyChatGPTServiceType, plugins: [LegacyChatPlugin.Type]) {
self.chatGPTService = chatGPTService
- var all = [String: ChatPlugin.Type]()
+ var all = [String: LegacyChatPlugin.Type]()
for plugin in plugins {
all[plugin.command.lowercased()] = plugin
}
self.plugins = all
}
- convenience init(chatGPTService: any LegacyChatGPTServiceType, plugins: ChatPlugin.Type...) {
+ convenience init(
+ chatGPTService: any LegacyChatGPTServiceType,
+ plugins: LegacyChatPlugin.Type...
+ ) {
self.init(chatGPTService: chatGPTService, plugins: plugins)
}
@@ -94,11 +97,11 @@ final class ChatPluginController {
return false
}
}
-
+
func stopResponding() async {
await runningPlugin?.stopResponding()
}
-
+
func cancel() async {
await runningPlugin?.cancel()
}
@@ -106,26 +109,29 @@ final class ChatPluginController {
// MARK: - ChatPluginDelegate
-extension ChatPluginController: ChatPluginDelegate {
- public func pluginDidStartResponding(_: ChatPlugin) {
+extension ChatPluginController: LegacyChatPluginDelegate {
+ public func pluginDidStartResponding(_: LegacyChatPlugin) {
chatService?.isReceivingMessage = true
}
- public func pluginDidEndResponding(_: ChatPlugin) {
+ public func pluginDidEndResponding(_: LegacyChatPlugin) {
chatService?.isReceivingMessage = false
}
- public func pluginDidStart(_ plugin: ChatPlugin) {
+ public func pluginDidStart(_ plugin: LegacyChatPlugin) {
runningPlugin = plugin
}
- public func pluginDidEnd(_ plugin: ChatPlugin) {
+ public func pluginDidEnd(_ plugin: LegacyChatPlugin) {
if runningPlugin === plugin {
runningPlugin = nil
}
}
- public func shouldStartAnotherPlugin(_ type: ChatPlugin.Type, withContent content: String) {
+ public func shouldStartAnotherPlugin(
+ _ type: LegacyChatPlugin.Type,
+ withContent content: String
+ ) {
let plugin = type.init(inside: chatGPTService, delegate: self)
Task {
await plugin.send(content: content, originalMessage: content)
diff --git a/Core/Sources/ChatService/ChatService.swift b/Core/Sources/ChatService/ChatService.swift
index 029fface..e1b0eb54 100644
--- a/Core/Sources/ChatService/ChatService.swift
+++ b/Core/Sources/ChatService/ChatService.swift
@@ -1,13 +1,14 @@
import ChatContextCollector
-import ChatPlugin
+import LegacyChatPlugin
import Combine
+import CustomCommandTemplateProcessor
import Foundation
import OpenAIService
import Preferences
public final class ChatService: ObservableObject {
public typealias Scope = ChatContext.Scope
-
+
public let memory: ContextAwareAutoManagedChatGPTMemory
public let configuration: OverridingChatGPTConfiguration
public let chatGPTService: any LegacyChatGPTServiceType
@@ -91,7 +92,7 @@ public final class ChatService: ObservableObject {
if UserDefaults.shared.value(for: \.enableWebScopeByDefaultInChatContext) {
scopes.insert(.web)
}
-
+
defaultScopes = scopes
}
diff --git a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManagementView.swift b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManagementView.swift
index 8c06d2dc..bc6c910e 100644
--- a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManagementView.swift
+++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManagementView.swift
@@ -82,8 +82,10 @@ struct APIKeyManagementView: View {
state: \.apiKeySubmission,
action: \.apiKeySubmission
)) { store in
- APIKeySubmissionView(store: store)
- .frame(minWidth: 400)
+ WithPerceptionTracking {
+ APIKeySubmissionView(store: store)
+ .frame(minWidth: 400)
+ }
}
}
}
diff --git a/Core/Sources/HostApp/AccountSettings/BingSearchView.swift b/Core/Sources/HostApp/AccountSettings/BingSearchView.swift
deleted file mode 100644
index 7504e828..00000000
--- a/Core/Sources/HostApp/AccountSettings/BingSearchView.swift
+++ /dev/null
@@ -1,51 +0,0 @@
-import AppKit
-import Client
-import OpenAIService
-import Preferences
-import SuggestionBasic
-import SwiftUI
-
-final class BingSearchViewSettings: ObservableObject {
- @AppStorage(\.bingSearchSubscriptionKey) var bingSearchSubscriptionKey: String
- @AppStorage(\.bingSearchEndpoint) var bingSearchEndpoint: String
- init() {}
-}
-
-struct BingSearchView: View {
- @Environment(\.openURL) var openURL
- @StateObject var settings = BingSearchViewSettings()
-
- var body: some View {
- Form {
- Button(action: {
- let url = URL(string: "https://www.microsoft.com/bing/apis/bing-web-search-api")!
- openURL(url)
- }) {
- Text("Apply for Subscription Key for Free")
- }
-
- SecureField(text: $settings.bingSearchSubscriptionKey, prompt: Text("")) {
- Text("Bing Search Subscription Key")
- }
- .textFieldStyle(.roundedBorder)
-
- TextField(
- text: $settings.bingSearchEndpoint,
- prompt: Text("https://api.bing.microsoft.com/***")
- ) {
- Text("Bing Search Endpoint")
- }.textFieldStyle(.roundedBorder)
- }
- }
-}
-
-struct BingSearchView_Previews: PreviewProvider {
- static var previews: some View {
- VStack(alignment: .leading, spacing: 8) {
- BingSearchView()
- }
- .frame(height: 800)
- .padding(.all, 8)
- }
-}
-
diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift
index 0f5ec8f8..f0c673e5 100644
--- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift
+++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift
@@ -31,6 +31,11 @@ struct ChatModelEdit {
var enforceMessageOrder: Bool = false
var openAIOrganizationID: String = ""
var openAIProjectID: String = ""
+ var customHeaders: [ChatModel.Info.CustomHeaderInfo.HeaderField] = []
+ var openAICompatibleSupportsMultipartMessageContent = true
+ var requiresBeginWithUserMessage = false
+ var customBody: String = ""
+ var supportsImages: Bool = true
}
enum Action: Equatable, BindableAction {
@@ -43,10 +48,44 @@ struct ChatModelEdit {
case testSucceeded(String)
case testFailed(String)
case checkSuggestedMaxTokens
+ case selectModelFormat(ModelFormat)
case apiKeySelection(APIKeySelection.Action)
case baseURLSelection(BaseURLSelection.Action)
}
+ enum ModelFormat: CaseIterable {
+ case openAI
+ case azureOpenAI
+ case googleAI
+ case ollama
+ case claude
+ case gitHubCopilot
+ case openAICompatible
+ case deepSeekOpenAICompatible
+ case openRouterOpenAICompatible
+ case grokOpenAICompatible
+ case mistralOpenAICompatible
+
+ init(_ format: ChatModel.Format) {
+ switch format {
+ case .openAI:
+ self = .openAI
+ case .azureOpenAI:
+ self = .azureOpenAI
+ case .googleAI:
+ self = .googleAI
+ case .ollama:
+ self = .ollama
+ case .claude:
+ self = .claude
+ case .openAICompatible:
+ self = .openAICompatible
+ case .gitHubCopilot:
+ self = .gitHubCopilot
+ }
+ }
+ }
+
var toast: (String, ToastType) -> Void {
@Dependency(\.namespacedToast) var toast
return {
@@ -87,21 +126,33 @@ struct ChatModelEdit {
let model = ChatModel(state: state)
return .run { send in
do {
- let service = LegacyChatGPTService(
- configuration: UserPreferenceChatGPTConfiguration()
- .overriding {
- $0.model = model
- }
- )
- let reply = try await service
- .sendAndWait(content: "Respond with \"Test succeeded\"")
- await send(.testSucceeded(reply ?? "No Message"))
- let stream = try await service
- .send(content: "Respond with \"Stream response is working\"")
- var streamReply = ""
- for try await chunk in stream {
- streamReply += chunk
+ let configuration = UserPreferenceChatGPTConfiguration().overriding {
+ $0.model = model
}
+ let service = ChatGPTService(configuration: configuration)
+ let stream = service.send(TemplateChatGPTMemory(
+ memoryTemplate: .init(messages: [
+ .init(chatMessage: .init(
+ role: .system,
+ content: "You are a bot. Just do what is told."
+ )),
+ .init(chatMessage: .init(
+ role: .assistant,
+ content: "Hello"
+ )),
+ .init(chatMessage: .init(
+ role: .user,
+ content: "Respond with \"Test succeeded.\""
+ )),
+ .init(chatMessage: .init(
+ role: .user,
+ content: "Respond with \"Test succeeded.\""
+ )),
+ ]),
+ configuration: configuration,
+ functionProvider: NoChatGPTFunctionProvider()
+ ))
+ let streamReply = try await stream.asText()
await send(.testSucceeded(streamReply))
} catch {
await send(.testFailed(error.localizedDescription))
@@ -150,11 +201,53 @@ struct ChatModelEdit {
state.suggestedMaxTokens = nil
}
return .none
+ case .gitHubCopilot:
+ if let knownModel = AvailableGitHubCopilotModel(rawValue: state.modelName) {
+ state.suggestedMaxTokens = knownModel.contextWindow
+ } else {
+ state.suggestedMaxTokens = nil
+ }
+ return .none
default:
state.suggestedMaxTokens = nil
return .none
}
+ case let .selectModelFormat(format):
+ switch format {
+ case .openAI:
+ state.format = .openAI
+ case .azureOpenAI:
+ state.format = .azureOpenAI
+ case .googleAI:
+ state.format = .googleAI
+ case .ollama:
+ state.format = .ollama
+ case .claude:
+ state.format = .claude
+ case .gitHubCopilot:
+ state.format = .gitHubCopilot
+ case .openAICompatible:
+ state.format = .openAICompatible
+ case .deepSeekOpenAICompatible:
+ state.format = .openAICompatible
+ state.baseURLSelection.baseURL = "https://api.deepseek.com"
+ state.baseURLSelection.isFullURL = false
+ case .openRouterOpenAICompatible:
+ state.format = .openAICompatible
+ state.baseURLSelection.baseURL = "https://openrouter.ai"
+ state.baseURLSelection.isFullURL = false
+ case .grokOpenAICompatible:
+ state.format = .openAICompatible
+ state.baseURLSelection.baseURL = "https://api.x.ai"
+ state.baseURLSelection.isFullURL = false
+ case .mistralOpenAICompatible:
+ state.format = .openAICompatible
+ state.baseURLSelection.baseURL = "https://api.mistral.ai"
+ state.baseURLSelection.isFullURL = false
+ }
+ return .none
+
case .apiKeySelection:
return .none
@@ -194,18 +287,27 @@ extension ChatModel {
switch state.format {
case .googleAI, .ollama, .claude:
return false
- case .azureOpenAI, .openAI, .openAICompatible:
+ case .azureOpenAI, .openAI, .openAICompatible, .gitHubCopilot:
return state.supportsFunctionCalling
}
}(),
- modelName: state.modelName.trimmingCharacters(in: .whitespacesAndNewlines),
+ supportsImage: state.supportsImages,
+ modelName: state.modelName
+ .trimmingCharacters(in: .whitespacesAndNewlines),
openAIInfo: .init(
organizationID: state.openAIOrganizationID,
projectID: state.openAIProjectID
),
ollamaInfo: .init(keepAlive: state.ollamaKeepAlive),
googleGenerativeAIInfo: .init(apiVersion: state.apiVersion),
- openAICompatibleInfo: .init(enforceMessageOrder: state.enforceMessageOrder)
+ openAICompatibleInfo: .init(
+ enforceMessageOrder: state.enforceMessageOrder,
+ supportsMultipartMessageContent: state
+ .openAICompatibleSupportsMultipartMessageContent,
+ requiresBeginWithUserMessage: state.requiresBeginWithUserMessage
+ ),
+ customHeaderInfo: .init(headers: state.customHeaders),
+ customBodyInfo: .init(jsonBody: state.customBody)
)
)
}
@@ -227,7 +329,13 @@ extension ChatModel {
baseURLSelection: .init(baseURL: info.baseURL, isFullURL: info.isFullURL),
enforceMessageOrder: info.openAICompatibleInfo.enforceMessageOrder,
openAIOrganizationID: info.openAIInfo.organizationID,
- openAIProjectID: info.openAIInfo.projectID
+ openAIProjectID: info.openAIInfo.projectID,
+ customHeaders: info.customHeaderInfo.headers,
+ openAICompatibleSupportsMultipartMessageContent: info.openAICompatibleInfo
+ .supportsMultipartMessageContent,
+ requiresBeginWithUserMessage: info.openAICompatibleInfo.requiresBeginWithUserMessage,
+ customBody: info.customBodyInfo.jsonBody,
+ supportsImages: info.supportsImage
)
}
}
diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift
index 77605eac..d16b7556 100644
--- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift
+++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift
@@ -29,6 +29,8 @@ struct ChatModelEditView: View {
OllamaForm(store: store)
case .claude:
ClaudeForm(store: store)
+ case .gitHubCopilot:
+ GitHubCopilotForm(store: store)
}
}
.padding()
@@ -48,6 +50,25 @@ struct ChatModelEditView: View {
}
}
+ CustomBodyEdit(store: store)
+ .disabled({
+ switch store.format {
+ case .openAI, .openAICompatible, .claude:
+ return false
+ default:
+ return true
+ }
+ }())
+ CustomHeaderEdit(store: store)
+ .disabled({
+ switch store.format {
+ case .openAI, .openAICompatible, .ollama, .gitHubCopilot, .claude:
+ return false
+ default:
+ return true
+ }
+ }())
+
Spacer()
Button("Cancel") {
@@ -86,31 +107,44 @@ struct ChatModelEditView: View {
var body: some View {
WithPerceptionTracking {
Picker(
- selection: $store.format,
+ selection: Binding(
+ get: { .init(store.format) },
+ set: { store.send(.selectModelFormat($0)) }
+ ),
content: {
ForEach(
- ChatModel.Format.allCases,
- id: \.rawValue
+ ChatModelEdit.ModelFormat.allCases,
+ id: \.self
) { format in
switch format {
case .openAI:
- Text("OpenAI").tag(format)
+ Text("OpenAI")
case .azureOpenAI:
- Text("Azure OpenAI").tag(format)
+ Text("Azure OpenAI")
case .openAICompatible:
- Text("OpenAI Compatible").tag(format)
+ Text("OpenAI Compatible")
case .googleAI:
- Text("Google Generative AI").tag(format)
+ Text("Google AI")
case .ollama:
- Text("Ollama").tag(format)
+ Text("Ollama")
case .claude:
- Text("Claude").tag(format)
+ Text("Claude")
+ case .gitHubCopilot:
+ Text("GitHub Copilot")
+ case .deepSeekOpenAICompatible:
+ Text("DeepSeek (OpenAI Compatible)")
+ case .openRouterOpenAICompatible:
+ Text("OpenRouter (OpenAI Compatible)")
+ case .grokOpenAICompatible:
+ Text("Grok (OpenAI Compatible)")
+ case .mistralOpenAICompatible:
+ Text("Mistral (OpenAI Compatible)")
}
}
},
label: { Text("Format") }
)
- .pickerStyle(.segmented)
+ .pickerStyle(.menu)
}
}
}
@@ -215,6 +249,79 @@ struct ChatModelEditView: View {
}
}
+ struct CustomBodyEdit: View {
+ @Perception.Bindable var store: StoreOf
+ @State private var isEditing = false
+ @Dependency(\.namespacedToast) var toast
+
+ var body: some View {
+ Button("Custom Body") {
+ isEditing = true
+ }
+ .sheet(isPresented: $isEditing) {
+ WithPerceptionTracking {
+ VStack {
+ TextEditor(text: $store.customBody)
+ .font(Font.system(.body, design: .monospaced))
+ .padding(4)
+ .frame(minHeight: 120)
+ .multilineTextAlignment(.leading)
+ .overlay(
+ RoundedRectangle(cornerRadius: 4)
+ .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
+ )
+ .handleToast(namespace: "CustomBodyEdit")
+
+ Text(
+ "The custom body will be added to the request body. Please use it to add parameters that are not yet available in the form. It should be a valid JSON object."
+ )
+ .foregroundColor(.secondary)
+ .font(.callout)
+ .padding(.bottom)
+
+ Button(action: {
+ if store.customBody.trimmingCharacters(in: .whitespacesAndNewlines)
+ .isEmpty
+ {
+ isEditing = false
+ return
+ }
+ guard let _ = try? JSONSerialization
+ .jsonObject(with: store.customBody.data(using: .utf8) ?? Data())
+ else {
+ toast("Invalid JSON object", .error, "CustomBodyEdit")
+ return
+ }
+ isEditing = false
+ }) {
+ Text("Done")
+ }
+ .keyboardShortcut(.defaultAction)
+ }
+ .padding()
+ .frame(width: 600, height: 500)
+ .background(Color(nsColor: .windowBackgroundColor))
+ }
+ }
+ }
+ }
+
+ struct CustomHeaderEdit: View {
+ @Perception.Bindable var store: StoreOf
+ @State private var isEditing = false
+
+ var body: some View {
+ Button("Custom Headers") {
+ isEditing = true
+ }
+ .sheet(isPresented: $isEditing) {
+ WithPerceptionTracking {
+ CustomHeaderSettingsView(headers: $store.customHeaders)
+ }
+ }
+ }
+ }
+
struct OpenAIForm: View {
@Perception.Bindable var store: StoreOf
var body: some View {
@@ -243,7 +350,7 @@ struct ChatModelEditView: View {
MaxTokensTextField(store: store)
SupportsFunctionCallingToggle(store: store)
-
+
TextField(text: $store.openAIOrganizationID, prompt: Text("Optional")) {
Text("Organization ID")
}
@@ -251,6 +358,10 @@ struct ChatModelEditView: View {
TextField(text: $store.openAIProjectID, prompt: Text("Optional")) {
Text("Project ID")
}
+
+ Toggle(isOn: $store.supportsImages) {
+ Text("Supports Images")
+ }
VStack(alignment: .leading, spacing: 8) {
Text(Image(systemName: "exclamationmark.triangle.fill")) + Text(
@@ -279,6 +390,10 @@ struct ChatModelEditView: View {
MaxTokensTextField(store: store)
SupportsFunctionCallingToggle(store: store)
+
+ Toggle(isOn: $store.supportsImages) {
+ Text("Supports Images")
+ }
}
}
}
@@ -320,6 +435,18 @@ struct ChatModelEditView: View {
Toggle(isOn: $store.enforceMessageOrder) {
Text("Enforce message order to be user/assistant alternated")
}
+
+ Toggle(isOn: $store.openAICompatibleSupportsMultipartMessageContent) {
+ Text("Support multi-part message content")
+ }
+
+ Toggle(isOn: $store.requiresBeginWithUserMessage) {
+ Text("Requires the first message to be from the user")
+ }
+
+ Toggle(isOn: $store.supportsImages) {
+ Text("Supports Images")
+ }
}
}
}
@@ -358,18 +485,25 @@ struct ChatModelEditView: View {
MaxTokensTextField(store: store)
TextField("API Version", text: $store.apiVersion, prompt: Text("v1"))
+
+ Toggle(isOn: $store.supportsImages) {
+ Text("Supports Images")
+ }
}
}
}
struct OllamaForm: View {
@Perception.Bindable var store: StoreOf
+
var body: some View {
WithPerceptionTracking {
BaseURLTextField(store: store, prompt: Text("http://127.0.0.1:11434")) {
Text("/api/chat")
}
+ ApiKeyNamePicker(store: store)
+
TextField("Model Name", text: $store.modelName)
MaxTokensTextField(store: store)
@@ -378,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)."
@@ -421,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(
@@ -431,6 +573,48 @@ struct ChatModelEditView: View {
}
}
}
+
+ struct GitHubCopilotForm: View {
+ @Perception.Bindable var store: StoreOf
+
+ var body: some View {
+ WithPerceptionTracking {
+ #warning("Todo: use the old picker and update the context window limit.")
+ GitHubCopilotModelPicker(
+ title: "Model Name",
+ hasDefaultModel: false,
+ gitHubCopilotModelId: $store.modelName
+ )
+
+ MaxTokensTextField(store: store)
+ SupportsFunctionCallingToggle(store: store)
+
+ Toggle(isOn: $store.enforceMessageOrder) {
+ Text("Enforce message order to be user/assistant alternated")
+ }
+
+ Toggle(isOn: $store.openAICompatibleSupportsMultipartMessageContent) {
+ Text("Support multi-part message content")
+ }
+
+ Toggle(isOn: $store.supportsImages) {
+ Text("Supports Images")
+ }
+
+ VStack(alignment: .leading, spacing: 8) {
+ Text(Image(systemName: "exclamationmark.triangle.fill")) + Text(
+ " Please login in the GitHub Copilot settings to use the model."
+ )
+
+ Text(Image(systemName: "exclamationmark.triangle.fill")) + Text(
+ " This will call the APIs directly, which may not be allowed by GitHub. But it's used in other popular apps like Zed."
+ )
+ }
+ .dynamicHeightTextInFormWorkaround()
+ .padding(.vertical)
+ }
+ }
+ }
}
#Preview("OpenAI") {
diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagement.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagement.swift
index 4dc46630..64eadd57 100644
--- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagement.swift
+++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagement.swift
@@ -13,6 +13,7 @@ extension ChatModel: ManageableAIModel {
case .googleAI: return "Google Generative AI"
case .ollama: return "Ollama"
case .claude: return "Claude"
+ case .gitHubCopilot: return "GitHub Copilot"
}
}
diff --git a/Core/Sources/HostApp/AccountSettings/CodeiumView.swift b/Core/Sources/HostApp/AccountSettings/CodeiumView.swift
index 217f8167..e60af2a8 100644
--- a/Core/Sources/HostApp/AccountSettings/CodeiumView.swift
+++ b/Core/Sources/HostApp/AccountSettings/CodeiumView.swift
@@ -151,7 +151,7 @@ struct CodeiumView: View {
Text("Language Server Version: \(version)")
uninstallButton
}
- case let .outdated(current: current, latest: latest):
+ case let .outdated(current: current, latest: latest, _):
HStack {
Text("Language Server Version: \(current) (Update Available: \(latest))")
uninstallButton
@@ -323,7 +323,7 @@ struct CodeiumView_Previews: PreviewProvider {
CodeiumView(viewModel: TestViewModel(
isSignedIn: true,
- installationStatus: .outdated(current: "1.2.9", latest: "1.3.0"),
+ installationStatus: .outdated(current: "1.2.9", latest: "1.3.0", mandatory: true),
installationStep: .downloading
))
diff --git a/Core/Sources/HostApp/AccountSettings/CustomHeaderSettingsView.swift b/Core/Sources/HostApp/AccountSettings/CustomHeaderSettingsView.swift
new file mode 100644
index 00000000..97db69b5
--- /dev/null
+++ b/Core/Sources/HostApp/AccountSettings/CustomHeaderSettingsView.swift
@@ -0,0 +1,78 @@
+import AIModel
+import Foundation
+import SwiftUI
+
+struct CustomHeaderSettingsView: View {
+ @Binding var headers: [ChatModel.Info.CustomHeaderInfo.HeaderField]
+ @Environment(\.dismiss) var dismiss
+ @State private var newKey = ""
+ @State private var newValue = ""
+
+ var body: some View {
+ VStack {
+ List {
+ ForEach(headers.indices, id: \.self) { index in
+ HStack {
+ TextField("Key", text: Binding(
+ get: { headers[index].key },
+ set: { newKey in
+ headers[index].key = newKey
+ }
+ ))
+ TextField("Value", text: Binding(
+ get: { headers[index].value },
+ set: { headers[index].value = $0 }
+ ))
+ Button(action: {
+ headers.remove(at: index)
+ }) {
+ Image(systemName: "trash")
+ .foregroundColor(.red)
+ }
+ }
+ }
+
+ HStack {
+ TextField("New Key", text: $newKey)
+ TextField("New Value", text: $newValue)
+ Button(action: {
+ if !newKey.isEmpty {
+ headers.append(ChatModel.Info.CustomHeaderInfo.HeaderField(
+ key: newKey,
+ value: newValue
+ ))
+ newKey = ""
+ newValue = ""
+ }
+ }) {
+ Image(systemName: "plus.circle.fill")
+ .foregroundColor(.green)
+ }
+ }
+ }
+
+ HStack {
+ Spacer()
+ Button("Done") {
+ dismiss()
+ }
+ }.padding()
+ }
+ .frame(height: 500)
+ }
+}
+
+#Preview {
+ struct V: View {
+ @State var headers: [ChatModel.Info.CustomHeaderInfo.HeaderField] = [
+ .init(key: "key", value: "value"),
+ .init(key: "key2", value: "value2"),
+ ]
+ var body: some View {
+ CustomHeaderSettingsView(headers: $headers)
+ }
+ }
+
+ return V()
+}
+
diff --git a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift
index 5506ba4f..f057be21 100644
--- a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift
+++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift
@@ -15,6 +15,7 @@ struct EmbeddingModelEdit {
var name: String
var format: EmbeddingModel.Format
var maxTokens: Int = 8191
+ var dimensions: Int = 1536
var modelName: String = ""
var ollamaKeepAlive: String = ""
var apiKeyName: String { apiKeySelection.apiKeyName }
@@ -26,6 +27,7 @@ struct EmbeddingModelEdit {
var suggestedMaxTokens: Int?
var apiKeySelection: APIKeySelection.State = .init()
var baseURLSelection: BaseURLSelection.State = .init()
+ var customHeaders: [ChatModel.Info.CustomHeaderInfo.HeaderField] = []
}
enum Action: Equatable, BindableAction {
@@ -37,10 +39,37 @@ struct EmbeddingModelEdit {
case testButtonClicked
case testSucceeded(String)
case testFailed(String)
+ case fixDimensions(Int)
case checkSuggestedMaxTokens
+ case selectModelFormat(ModelFormat)
case apiKeySelection(APIKeySelection.Action)
case baseURLSelection(BaseURLSelection.Action)
}
+
+ enum ModelFormat: CaseIterable {
+ case openAI
+ case azureOpenAI
+ case ollama
+ case gitHubCopilot
+ case openAICompatible
+ case mistralOpenAICompatible
+ case voyageAIOpenAICompatible
+
+ init(_ format: EmbeddingModel.Format) {
+ switch format {
+ case .openAI:
+ self = .openAI
+ case .azureOpenAI:
+ self = .azureOpenAI
+ case .ollama:
+ self = .ollama
+ case .openAICompatible:
+ self = .openAICompatible
+ case .gitHubCopilot:
+ self = .gitHubCopilot
+ }
+ }
+ }
var toast: (String, ToastType) -> Void {
@Dependency(\.namespacedToast) var toast
@@ -79,6 +108,7 @@ struct EmbeddingModelEdit {
case .testButtonClicked:
guard !state.isTesting else { return .none }
state.isTesting = true
+ let dimensions = state.dimensions
let model = EmbeddingModel(
id: state.id,
name: state.name,
@@ -88,18 +118,33 @@ struct EmbeddingModelEdit {
baseURL: state.baseURL,
isFullURL: state.isFullURL,
maxTokens: state.maxTokens,
+ dimensions: dimensions,
modelName: state.modelName
)
)
return .run { send in
do {
- _ = try await EmbeddingService(
+ let result = try await EmbeddingService(
configuration: UserPreferenceEmbeddingConfiguration()
.overriding {
$0.model = model
}
).embed(text: "Hello")
- await send(.testSucceeded("Succeeded!"))
+ if result.data.isEmpty {
+ await send(.testFailed("No data returned"))
+ return
+ }
+ let actualDimensions = result.data.first?.embedding.count ?? 0
+ if actualDimensions != dimensions {
+ await send(
+ .testFailed("Invalid dimension, should be \(actualDimensions)")
+ )
+ await send(.fixDimensions(actualDimensions))
+ } else {
+ await send(
+ .testSucceeded("Succeeded! (Dimensions: \(actualDimensions))")
+ )
+ }
} catch {
await send(.testFailed(error.localizedDescription))
}
@@ -130,6 +175,34 @@ struct EmbeddingModelEdit {
return .none
}
state.suggestedMaxTokens = knownModel.maxToken
+ state.dimensions = knownModel.dimensions
+ return .none
+
+ case let .fixDimensions(value):
+ state.dimensions = value
+ return .none
+
+ case let .selectModelFormat(format):
+ switch format {
+ case .openAI:
+ state.format = .openAI
+ case .azureOpenAI:
+ state.format = .azureOpenAI
+ case .ollama:
+ state.format = .ollama
+ case .openAICompatible:
+ state.format = .openAICompatible
+ case .gitHubCopilot:
+ state.format = .gitHubCopilot
+ case .mistralOpenAICompatible:
+ state.format = .openAICompatible
+ state.baseURLSelection.baseURL = "https://api.mistral.ai"
+ state.baseURLSelection.isFullURL = false
+ case .voyageAIOpenAICompatible:
+ state.format = .openAICompatible
+ state.baseURLSelection.baseURL = "https://api.voyage.ai"
+ state.baseURLSelection.isFullURL = false
+ }
return .none
case .apiKeySelection:
@@ -167,8 +240,10 @@ extension EmbeddingModel {
baseURL: state.baseURL.trimmingCharacters(in: .whitespacesAndNewlines),
isFullURL: state.isFullURL,
maxTokens: state.maxTokens,
+ dimensions: state.dimensions,
modelName: state.modelName.trimmingCharacters(in: .whitespacesAndNewlines),
- ollamaInfo: .init(keepAlive: state.ollamaKeepAlive)
+ ollamaInfo: .init(keepAlive: state.ollamaKeepAlive),
+ customHeaderInfo: .init(headers: state.customHeaders)
)
)
}
@@ -179,6 +254,7 @@ extension EmbeddingModel {
name: name,
format: format,
maxTokens: info.maxTokens,
+ dimensions: info.dimensions,
modelName: info.modelName,
ollamaKeepAlive: info.ollamaInfo.keepAlive,
apiKeySelection: .init(
@@ -188,7 +264,8 @@ extension EmbeddingModel {
baseURLSelection: .init(
baseURL: info.baseURL,
isFullURL: info.isFullURL
- )
+ ),
+ customHeaders: info.customHeaderInfo.headers
)
}
}
diff --git a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift
index 76f8a27d..46f4effd 100644
--- a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift
+++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift
@@ -24,6 +24,8 @@ struct EmbeddingModelEditView: View {
OpenAICompatibleForm(store: store)
case .ollama:
OllamaForm(store: store)
+ case .gitHubCopilot:
+ GitHubCopilotForm(store: store)
}
}
.padding()
@@ -81,27 +83,36 @@ struct EmbeddingModelEditView: View {
var body: some View {
WithPerceptionTracking {
Picker(
- selection: $store.format,
+ selection: Binding(
+ get: { .init(store.format) },
+ set: { store.send(.selectModelFormat($0)) }
+ ),
content: {
ForEach(
- EmbeddingModel.Format.allCases,
- id: \.rawValue
+ EmbeddingModelEdit.ModelFormat.allCases,
+ id: \.self
) { format in
switch format {
case .openAI:
- Text("OpenAI").tag(format)
+ Text("OpenAI")
case .azureOpenAI:
- Text("Azure OpenAI").tag(format)
- case .openAICompatible:
- Text("OpenAI Compatible").tag(format)
+ Text("Azure OpenAI")
case .ollama:
- Text("Ollama").tag(format)
+ Text("Ollama")
+ case .openAICompatible:
+ Text("OpenAI Compatible")
+ case .mistralOpenAICompatible:
+ Text("Mistral (OpenAI Compatible)")
+ case .voyageAIOpenAICompatible:
+ Text("Voyage (OpenAI Compatible)")
+ case .gitHubCopilot:
+ Text("GitHub Copilot")
}
}
},
label: { Text("Format") }
)
- .pickerStyle(.segmented)
+ .pickerStyle(.menu)
}
}
}
@@ -175,6 +186,51 @@ struct EmbeddingModelEditView: View {
}
}
+ struct DimensionsTextField: View {
+ @Perception.Bindable var store: StoreOf
+
+ var body: some View {
+ WithPerceptionTracking {
+ HStack {
+ let textFieldBinding = Binding(
+ get: { String(store.dimensions) },
+ set: {
+ if let selectionDimensions = Int($0) {
+ $store.dimensions.wrappedValue = selectionDimensions
+ } else {
+ $store.dimensions.wrappedValue = 0
+ }
+ }
+ )
+
+ TextField(text: textFieldBinding) {
+ Text("Dimensions")
+ .multilineTextAlignment(.trailing)
+ }
+ .overlay(alignment: .trailing) {
+ Stepper(
+ value: $store.dimensions,
+ in: 0...Int.max,
+ step: 100
+ ) {
+ EmptyView()
+ }
+ }
+ .foregroundColor({
+ if store.dimensions <= 0 {
+ return .red
+ }
+ return .primary
+ }() as Color)
+ }
+
+ Text("If you are not sure, run test to get the correct value.")
+ .font(.caption)
+ .dynamicHeightTextInFormWorkaround()
+ }
+ }
+ }
+
struct ApiKeyNamePicker: View {
let store: StoreOf
var body: some View {
@@ -215,6 +271,7 @@ struct EmbeddingModelEditView: View {
}
MaxTokensTextField(store: store)
+ DimensionsTextField(store: store)
VStack(alignment: .leading, spacing: 8) {
Text(Image(systemName: "exclamationmark.triangle.fill")) + Text(
@@ -242,12 +299,14 @@ struct EmbeddingModelEditView: View {
TextField("Deployment Name", text: $store.modelName)
MaxTokensTextField(store: store)
+ DimensionsTextField(store: store)
}
}
}
struct OpenAICompatibleForm: View {
@Perception.Bindable var store: StoreOf
+ @State var isEditingCustomHeader = false
var body: some View {
WithPerceptionTracking {
@@ -278,20 +337,33 @@ struct EmbeddingModelEditView: View {
TextField("Model Name", text: $store.modelName)
MaxTokensTextField(store: store)
+ DimensionsTextField(store: store)
+
+ Button("Custom Headers") {
+ isEditingCustomHeader.toggle()
+ }
+ }.sheet(isPresented: $isEditingCustomHeader) {
+ CustomHeaderSettingsView(headers: $store.customHeaders)
}
}
}
struct OllamaForm: View {
@Perception.Bindable var store: StoreOf
+ @State var isEditingCustomHeader = false
+
var body: some View {
WithPerceptionTracking {
BaseURLTextField(store: store, prompt: Text("http://127.0.0.1:11434")) {
Text("/api/embeddings")
}
+
+ ApiKeyNamePicker(store: store)
+
TextField("Model Name", text: $store.modelName)
MaxTokensTextField(store: store)
+ DimensionsTextField(store: store)
WithPerceptionTracking {
TextField(text: $store.ollamaKeepAlive, prompt: Text("Default Value")) {
@@ -299,12 +371,66 @@ struct EmbeddingModelEditView: View {
}
}
+ Button("Custom Headers") {
+ isEditingCustomHeader.toggle()
+ }
+
VStack(alignment: .leading, spacing: 8) {
Text(Image(systemName: "exclamationmark.triangle.fill")) + Text(
" For more details, please visit [https://ollama.com](https://ollama.com)."
)
}
.padding(.vertical)
+
+ }.sheet(isPresented: $isEditingCustomHeader) {
+ CustomHeaderSettingsView(headers: $store.customHeaders)
+ }
+ }
+ }
+
+ struct GitHubCopilotForm: View {
+ @Perception.Bindable var store: StoreOf
+ @State var isEditingCustomHeader = false
+
+ var body: some View {
+ WithPerceptionTracking {
+ TextField("Model Name", text: $store.modelName)
+ .overlay(alignment: .trailing) {
+ Picker(
+ "",
+ selection: $store.modelName,
+ content: {
+ if OpenAIEmbeddingModel(rawValue: store.modelName) == nil {
+ Text("Custom Model").tag(store.modelName)
+ }
+ ForEach(OpenAIEmbeddingModel.allCases, id: \.self) { model in
+ Text(model.rawValue).tag(model.rawValue)
+ }
+ }
+ )
+ .frame(width: 20)
+ }
+
+ MaxTokensTextField(store: store)
+ DimensionsTextField(store: store)
+
+ Button("Custom Headers") {
+ isEditingCustomHeader.toggle()
+ }
+
+ VStack(alignment: .leading, spacing: 8) {
+ Text(Image(systemName: "exclamationmark.triangle.fill")) + Text(
+ " Please login in the GitHub Copilot settings to use the model."
+ )
+
+ Text(Image(systemName: "exclamationmark.triangle.fill")) + Text(
+ " This will call the APIs directly, which may not be allowed by GitHub. But it's used in other popular apps like Zed."
+ )
+ }
+ .dynamicHeightTextInFormWorkaround()
+ .padding(.vertical)
+ }.sheet(isPresented: $isEditingCustomHeader) {
+ CustomHeaderSettingsView(headers: $store.customHeaders)
}
}
}
diff --git a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagement.swift b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagement.swift
index 294ca401..156f58ac 100644
--- a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagement.swift
+++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagement.swift
@@ -11,6 +11,7 @@ extension EmbeddingModel: ManageableAIModel {
case .azureOpenAI: return "Azure OpenAI"
case .openAICompatible: return "OpenAI Compatible"
case .ollama: return "Ollama"
+ case .gitHubCopilot: return "GitHub Copilot"
}
}
diff --git a/Core/Sources/HostApp/AccountSettings/GitHubCopilotModelPicker.swift b/Core/Sources/HostApp/AccountSettings/GitHubCopilotModelPicker.swift
new file mode 100644
index 00000000..9f4b0d8d
--- /dev/null
+++ b/Core/Sources/HostApp/AccountSettings/GitHubCopilotModelPicker.swift
@@ -0,0 +1,91 @@
+import Dependencies
+import Foundation
+import GitHubCopilotService
+import Perception
+import SwiftUI
+import Toast
+
+public struct GitHubCopilotModelPicker: View {
+ @Perceptible
+ final class ViewModel {
+ var availableModels: [GitHubCopilotLLMModel] = []
+ @PerceptionIgnored @Dependency(\.toast) var toast
+
+ init() {}
+
+ func appear() {
+ reloadAvailableModels()
+ }
+
+ func disappear() {}
+
+ func reloadAvailableModels() {
+ Task { @MainActor in
+ do {
+ availableModels = try await GitHubCopilotExtension.fetchLLMModels()
+ } catch {
+ toast("Failed to fetch GitHub Copilot models: \(error)", .error)
+ }
+ }
+ }
+ }
+
+ let title: String
+ let hasDefaultModel: Bool
+ @Binding var gitHubCopilotModelId: String
+ @State var viewModel: ViewModel
+
+ init(
+ title: String,
+ hasDefaultModel: Bool = true,
+ gitHubCopilotModelId: Binding
+ ) {
+ self.title = title
+ _gitHubCopilotModelId = gitHubCopilotModelId
+ self.hasDefaultModel = hasDefaultModel
+ viewModel = .init()
+ }
+
+ public var body: some View {
+ WithPerceptionTracking {
+ TextField(title, text: $gitHubCopilotModelId)
+ .overlay(alignment: .trailing) {
+ Picker(
+ "",
+ selection: $gitHubCopilotModelId,
+ content: {
+ if hasDefaultModel {
+ Text("Default").tag("")
+ }
+
+ if !gitHubCopilotModelId.isEmpty,
+ !viewModel.availableModels.contains(where: {
+ $0.modelId == gitHubCopilotModelId
+ })
+ {
+ Text(gitHubCopilotModelId).tag(gitHubCopilotModelId)
+ }
+ if viewModel.availableModels.isEmpty {
+ Text({
+ viewModel.reloadAvailableModels()
+ return "Loading..."
+ }()).tag("Loading...")
+ }
+ ForEach(viewModel.availableModels) { model in
+ Text(model.modelId)
+ .tag(model.modelId)
+ }
+ }
+ )
+ .frame(width: 20)
+ }
+ .onAppear {
+ viewModel.appear()
+ }
+ .onDisappear {
+ viewModel.disappear()
+ }
+ }
+ }
+}
+
diff --git a/Core/Sources/HostApp/AccountSettings/GitHubCopilotView.swift b/Core/Sources/HostApp/AccountSettings/GitHubCopilotView.swift
index 8fd049c1..ec627113 100644
--- a/Core/Sources/HostApp/AccountSettings/GitHubCopilotView.swift
+++ b/Core/Sources/HostApp/AccountSettings/GitHubCopilotView.swift
@@ -25,6 +25,7 @@ struct GitHubCopilotView: View {
var disableGitHubCopilotSettingsAutoRefreshOnAppear
@AppStorage(\.gitHubCopilotLoadKeyChainCertificates)
var gitHubCopilotLoadKeyChainCertificates
+ @AppStorage(\.gitHubCopilotModelId) var gitHubCopilotModelId
init() {}
}
@@ -157,7 +158,7 @@ struct GitHubCopilotView: View {
"node"
)
) {
- Text("Path to Node (v18+)")
+ Text("Path to Node (v22.0+)")
}
Text(
@@ -199,7 +200,7 @@ struct GitHubCopilotView: View {
.foregroundColor(.secondary)
.font(.callout)
.dynamicHeightTextInFormWorkaround()
-
+
Toggle(isOn: $settings.gitHubCopilotLoadKeyChainCertificates) {
Text("Load certificates in keychain")
}
@@ -260,13 +261,20 @@ struct GitHubCopilotView: View {
if isRunningAction {
ActivityIndicatorView()
}
- }
+ }
.opacity(isRunningAction ? 0.8 : 1)
.disabled(isRunningAction)
Button("Refresh configurations") {
refreshConfiguration()
}
+
+ Form {
+ GitHubCopilotModelPicker(
+ title: "Chat Model Name",
+ gitHubCopilotModelId: $settings.gitHubCopilotModelId
+ )
+ }
}
SettingsDivider("Advanced")
@@ -349,7 +357,6 @@ struct GitHubCopilotView: View {
if status != .ok, status != .notSignedIn {
toast(
"GitHub Copilot status is not \"ok\". Please check if you have a valid GitHub Copilot subscription.",
-
.error
)
}
diff --git a/Core/Sources/HostApp/AccountSettings/WebSearchView.swift b/Core/Sources/HostApp/AccountSettings/WebSearchView.swift
new file mode 100644
index 00000000..d34686f9
--- /dev/null
+++ b/Core/Sources/HostApp/AccountSettings/WebSearchView.swift
@@ -0,0 +1,277 @@
+import AppKit
+import Client
+import ComposableArchitecture
+import OpenAIService
+import Preferences
+import SuggestionBasic
+import SwiftUI
+import WebSearchService
+import SharedUIComponents
+
+@Reducer
+struct WebSearchSettings {
+ struct TestResult: Identifiable, Equatable {
+ let id = UUID()
+ var duration: TimeInterval
+ var result: Result?
+
+ static func == (lhs: Self, rhs: Self) -> Bool {
+ lhs.id == rhs.id
+ }
+ }
+
+ @ObservableState
+ struct State: Equatable {
+ var apiKeySelection: APIKeySelection.State = .init()
+ var testResult: TestResult?
+ }
+
+ enum Action: BindableAction {
+ case binding(BindingAction)
+ case appear
+ case test
+ case bringUpTestResult
+ case updateTestResult(TimeInterval, Result)
+ case apiKeySelection(APIKeySelection.Action)
+ }
+
+ var body: some ReducerOf {
+ BindingReducer()
+
+ Scope(state: \.apiKeySelection, action: \.apiKeySelection) {
+ APIKeySelection()
+ }
+
+ Reduce { state, action in
+ switch action {
+ case .binding:
+ return .none
+ case .appear:
+ state.testResult = nil
+ state.apiKeySelection.apiKeyName = UserDefaults.shared.value(for: \.serpAPIKeyName)
+ return .none
+ case .test:
+ return .run { send in
+ let searchService = WebSearchService(provider: .userPreferred)
+ await send(.bringUpTestResult)
+ let start = Date()
+ do {
+ let result = try await searchService.search(query: "Swift")
+ let duration = Date().timeIntervalSince(start)
+ await send(.updateTestResult(duration, .success(result)))
+ } catch {
+ let duration = Date().timeIntervalSince(start)
+ await send(.updateTestResult(duration, .failure(error)))
+ }
+ }
+ case .bringUpTestResult:
+ state.testResult = .init(duration: 0)
+ return .none
+ case let .updateTestResult(duration, result):
+ state.testResult?.duration = duration
+ state.testResult?.result = result
+ return .none
+ case let .apiKeySelection(action):
+ switch action {
+ case .binding(\APIKeySelection.State.apiKeyName):
+ UserDefaults.shared.set(state.apiKeySelection.apiKeyName, for: \.serpAPIKeyName)
+ return .none
+ default:
+ return .none
+ }
+ }
+ }
+ }
+}
+
+final class WebSearchViewSettings: ObservableObject {
+ @AppStorage(\.serpAPIEngine) var serpAPIEngine
+ @AppStorage(\.headlessBrowserEngine) var headlessBrowserEngine
+ @AppStorage(\.searchProvider) var searchProvider
+ init() {}
+}
+
+struct WebSearchView: View {
+ @Perception.Bindable var store: StoreOf
+ @Environment(\.openURL) var openURL
+ @StateObject var settings = WebSearchViewSettings()
+
+ var body: some View {
+ WithPerceptionTracking {
+ ScrollView {
+ VStack(alignment: .leading) {
+ Form {
+ Picker("Search Provider", selection: $settings.searchProvider) {
+ ForEach(UserDefaultPreferenceKeys.SearchProvider.allCases, id: \.self) {
+ provider in
+ switch provider {
+ case .serpAPI:
+ Text("Serp API").tag(provider)
+ case .headlessBrowser:
+ Text("Headless Browser").tag(provider)
+ }
+
+ }
+ }
+ .pickerStyle(.segmented)
+ }
+
+ switch settings.searchProvider {
+ case .serpAPI:
+ serpAPIForm()
+ case .headlessBrowser:
+ headlessBrowserForm()
+ }
+ }
+ .padding()
+ }
+ .safeAreaInset(edge: .bottom) {
+ VStack(spacing: 0) {
+ Divider()
+ HStack {
+ Button("Test Search") {
+ store.send(.test)
+ }
+ Spacer()
+ }
+ .padding()
+ }
+ .background(.regularMaterial)
+ }
+ .sheet(item: $store.testResult) { testResult in
+ testResultView(testResult: testResult)
+ }
+ .onAppear {
+ store.send(.appear)
+ }
+ }
+ }
+
+ @ViewBuilder
+ func serpAPIForm() -> some View {
+ SubSection(
+ title: Text("Serp API Settings"),
+ description: """
+ Use Serp API to do web search. Serp API is more reliable and faster than headless browser. But you need to provide an API key for it.
+ """
+ ) {
+ Picker("Engine", selection: $settings.serpAPIEngine) {
+ ForEach(
+ UserDefaultPreferenceKeys.SerpAPIEngine.allCases,
+ id: \.self
+ ) { engine in
+ Text(engine.rawValue).tag(engine)
+ }
+ }
+
+ WithPerceptionTracking {
+ APIKeyPicker(store: store.scope(
+ state: \.apiKeySelection,
+ action: \.apiKeySelection
+ ))
+ }
+ }
+ }
+
+ @ViewBuilder
+ func headlessBrowserForm() -> some View {
+ SubSection(
+ title: Text("Headless Browser Settings"),
+ description: """
+ The app will open a webview in the background to do web search. This method uses a set of rules to extract information from the web page, if you notice that it stops working, please submit an issue to the developer.
+ """
+ ) {
+ Picker("Engine", selection: $settings.headlessBrowserEngine) {
+ ForEach(
+ UserDefaultPreferenceKeys.HeadlessBrowserEngine.allCases,
+ id: \.self
+ ) { engine in
+ Text(engine.rawValue).tag(engine)
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ func testResultView(testResult: WebSearchSettings.TestResult) -> some View {
+ VStack {
+ Text("Test Result")
+ .padding(.top)
+ .font(.headline)
+
+ if let result = testResult.result {
+ switch result {
+ case let .success(webSearchResult):
+ VStack(alignment: .leading) {
+ Text("Success (Completed in \(testResult.duration, specifier: "%.2f")s)")
+ .foregroundColor(.green)
+
+ Text("Found \(webSearchResult.webPages.count) results:")
+
+ ScrollView {
+ ForEach(webSearchResult.webPages, id: \.urlString) { page in
+ HStack {
+ VStack(alignment: .leading) {
+ Text(page.title)
+ .font(.headline)
+ Text(page.urlString)
+ .font(.caption)
+ .foregroundColor(.blue)
+ Text(page.snippet)
+ .padding(.top, 2)
+ }
+ Spacer(minLength: 0)
+ }
+ .padding(.vertical, 4)
+ Divider()
+ }
+ }
+ }
+ .padding()
+ case let .failure(error):
+ VStack(alignment: .leading) {
+ Text("Error (Completed in \(testResult.duration, specifier: "%.2f")s)")
+ .foregroundColor(.red)
+ Text(error.localizedDescription)
+ }
+ }
+ } else {
+ ProgressView().padding()
+ }
+
+ Spacer()
+
+ VStack(spacing: 0) {
+ Divider()
+
+ HStack {
+ Spacer()
+
+ Button("Close") {
+ store.testResult = nil
+ }
+ .keyboardShortcut(.cancelAction)
+ }
+ .padding()
+ }
+ }
+ .frame(minWidth: 400, minHeight: 300)
+ }
+}
+
+// Helper struct to make TestResult identifiable for sheet presentation
+private struct TestResultWrapper: Identifiable {
+ var id: UUID = .init()
+ var testResult: WebSearchSettings.TestResult
+}
+
+struct WebSearchView_Previews: PreviewProvider {
+ static var previews: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ WebSearchView(store: .init(initialState: .init(), reducer: { WebSearchSettings() }))
+ }
+ .frame(height: 800)
+ .padding(.all, 8)
+ }
+}
+
diff --git a/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift b/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift
index 53f2d99c..033b9850 100644
--- a/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift
+++ b/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift
@@ -198,7 +198,7 @@ struct CustomCommandView: View {
VStack {
SubSection(title: Text("Send Message")) {
Text(
- "This command sends a message to the active chat tab. You can provide additional context through the \"Extra System Prompt\" as well."
+ "This command sends a message to the active chat tab. You can provide additional context as well. The additional context will be removed once a message is sent. If the message provided is empty, you can manually type the message in the chat."
)
}
SubSection(title: Text("Modification")) {
@@ -208,12 +208,12 @@ struct CustomCommandView: View {
}
SubSection(title: Text("Custom Chat")) {
Text(
- "This command will overwrite the system prompt to let the bot behave differently."
+ "This command will overwrite the context of the chat. You can use it to switch to different contexts in the chat. If a message is provided, it will be sent to the chat as well."
)
}
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."
)
}
}
@@ -275,7 +275,9 @@ struct CustomCommandView_Preview: PreviewProvider {
extraSystemPrompt: nil,
prompt: "Hello",
useExtraSystemPrompt: false
- )
+ ),
+ ignoreExistingAttachments: false,
+ attachments: []
),
.init(
commandId: "2",
@@ -285,7 +287,9 @@ struct CustomCommandView_Preview: PreviewProvider {
prompt: "Refactor",
continuousMode: false,
generateDescription: true
- )
+ ),
+ ignoreExistingAttachments: false,
+ attachments: []
),
], "CustomCommandView_Preview"))
@@ -299,7 +303,9 @@ struct CustomCommandView_Preview: PreviewProvider {
extraSystemPrompt: nil,
prompt: "Hello",
useExtraSystemPrompt: false
- )
+ ),
+ ignoreExistingAttachments: false,
+ attachments: [] as [CustomCommand.Attachment]
)))
),
reducer: { CustomCommandFeature(settings: settings) }
@@ -319,7 +325,9 @@ struct CustomCommandView_NoEditing_Preview: PreviewProvider {
extraSystemPrompt: nil,
prompt: "Hello",
useExtraSystemPrompt: false
- )
+ ),
+ ignoreExistingAttachments: false,
+ attachments: []
),
.init(
commandId: "2",
@@ -329,7 +337,9 @@ struct CustomCommandView_NoEditing_Preview: PreviewProvider {
prompt: "Refactor",
continuousMode: false,
generateDescription: true
- )
+ ),
+ ignoreExistingAttachments: false,
+ attachments: []
),
], "CustomCommandView_Preview"))
diff --git a/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommand.swift b/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommand.swift
index f914d068..e927a9ff 100644
--- a/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommand.swift
+++ b/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommand.swift
@@ -23,11 +23,16 @@ struct EditCustomCommand {
var promptToCode = EditPromptToCodeCommand.State()
var customChat = EditCustomChatCommand.State()
var singleRoundDialog = EditSingleRoundDialogCommand.State()
+ var attachments = EditCustomCommandAttachment.State()
init(_ command: CustomCommand?) {
isNewCommand = command == nil
commandId = command?.id ?? UUID().uuidString
name = command?.name ?? "New Command"
+ attachments = .init(
+ attachments: command?.attachments ?? [],
+ ignoreExistingAttachments: command?.ignoreExistingAttachments ?? false
+ )
switch command?.feature {
case let .chatWithSelection(extraSystemPrompt, prompt, useExtraSystemPrompt):
@@ -83,6 +88,7 @@ struct EditCustomCommand {
case promptToCode(EditPromptToCodeCommand.Action)
case customChat(EditCustomChatCommand.Action)
case singleRoundDialog(EditSingleRoundDialogCommand.Action)
+ case attachments(EditCustomCommandAttachment.Action)
}
let settings: CustomCommandView.Settings
@@ -106,6 +112,10 @@ struct EditCustomCommand {
EditSingleRoundDialogCommand()
}
+ Scope(state: \.attachments, action: \.attachments) {
+ EditCustomCommandAttachment()
+ }
+
BindingReducer()
Reduce { state, action in
@@ -151,7 +161,9 @@ struct EditCustomCommand {
receiveReplyInNotification: state.receiveReplyInNotification
)
}
- }()
+ }(),
+ ignoreExistingAttachments: state.attachments.ignoreExistingAttachments,
+ attachments: state.attachments.attachments
)
if state.isNewCommand {
@@ -184,6 +196,32 @@ struct EditCustomCommand {
return .none
case .singleRoundDialog:
return .none
+ case .attachments:
+ return .none
+ }
+ }
+ }
+}
+
+@Reducer
+struct EditCustomCommandAttachment {
+ @ObservableState
+ struct State: Equatable {
+ var attachments: [CustomCommand.Attachment] = []
+ var ignoreExistingAttachments: Bool = false
+ }
+
+ enum Action: BindableAction, Equatable {
+ case binding(BindingAction)
+ }
+
+ var body: some ReducerOf {
+ BindingReducer()
+
+ Reduce { _, action in
+ switch action {
+ case .binding:
+ return .none
}
}
}
diff --git a/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift b/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift
index 1dade9bb..e2304f8b 100644
--- a/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift
+++ b/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift
@@ -57,6 +57,10 @@ struct EditCustomCommandView: View {
store: store.scope(
state: \.sendMessage,
action: \.sendMessage
+ ),
+ attachmentStore: store.scope(
+ state: \.attachments,
+ action: \.attachments
)
)
case .promptToCode:
@@ -71,6 +75,10 @@ struct EditCustomCommandView: View {
store: store.scope(
state: \.customChat,
action: \.customChat
+ ),
+ attachmentStore: store.scope(
+ state: \.attachments,
+ action: \.attachments
)
)
case .singleRoundDialog:
@@ -88,19 +96,19 @@ struct EditCustomCommandView: View {
WithPerceptionTracking {
VStack {
Divider()
-
+
VStack(alignment: .trailing) {
Text(
"After renaming or adding a custom command, please restart Xcode to refresh the menu."
)
.foregroundStyle(.secondary)
-
+
HStack {
Spacer()
Button("Close") {
store.send(.close)
}
-
+
if store.isNewCommand {
Button("Add") {
store.send(.saveCommand)
@@ -120,22 +128,168 @@ struct EditCustomCommandView: View {
}
}
+struct CustomCommandAttachmentPickerView: View {
+ @Perception.Bindable var store: StoreOf
+ @State private var isFileInputPresented = false
+ @State private var filePath = ""
+
+ #if canImport(ProHostApp)
+ var body: some View {
+ WithPerceptionTracking {
+ VStack(alignment: .leading) {
+ Text("Contexts")
+
+ HStack(alignment: .top) {
+ VStack(alignment: .leading, spacing: 8) {
+ if store.attachments.isEmpty {
+ Text("No context")
+ .foregroundStyle(.secondary)
+ } else {
+ ForEach(store.attachments, id: \.kind) { attachment in
+ HStack {
+ switch attachment.kind {
+ case let .file(path: path):
+ HStack {
+ Text("File:")
+ Text(path).foregroundStyle(.secondary)
+ }
+ default:
+ Text(attachment.kind.description)
+ }
+ Spacer()
+ Button {
+ store.attachments.removeAll { $0.kind == attachment.kind }
+ } label: {
+ Image(systemName: "trash")
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+ }
+ .frame(minWidth: 240)
+ .padding(.vertical, 8)
+ .padding(.horizontal, 8)
+ .background {
+ RoundedRectangle(cornerRadius: 8)
+ .strokeBorder(.separator, lineWidth: 1)
+ }
+
+ Form {
+ Menu {
+ ForEach(CustomCommand.Attachment.Kind.allCases.filter { kind in
+ !store.attachments.contains { $0.kind == kind }
+ }, id: \.self) { kind in
+ if kind == .file(path: "") {
+ Button {
+ isFileInputPresented = true
+ } label: {
+ Text("File...")
+ }
+ } else {
+ Button {
+ store.attachments.append(.init(kind: kind))
+ } label: {
+ Text(kind.description)
+ }
+ }
+ }
+ } label: {
+ Label("Add context", systemImage: "plus")
+ }
+
+ Toggle(
+ "Ignore existing contexts",
+ isOn: $store.ignoreExistingAttachments
+ )
+ }
+ }
+ }
+ .sheet(isPresented: $isFileInputPresented) {
+ VStack(alignment: .leading, spacing: 16) {
+ Text("Enter file path:")
+ .font(.headline)
+ Text(
+ "You can enter either an absolute path or a path relative to the project root."
+ )
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ TextField("File path", text: $filePath)
+ .textFieldStyle(.roundedBorder)
+ HStack {
+ Spacer()
+ Button("Cancel") {
+ isFileInputPresented = false
+ filePath = ""
+ }
+ Button("Add") {
+ store.attachments.append(.init(kind: .file(path: filePath)))
+ isFileInputPresented = false
+ filePath = ""
+ }
+ .disabled(filePath.isEmpty)
+ }
+ }
+ .padding()
+ .frame(minWidth: 400)
+ }
+ }
+ }
+ #else
+ var body: some View { EmptyView() }
+ #endif
+}
+
+extension CustomCommand.Attachment.Kind {
+ public static var allCases: [CustomCommand.Attachment.Kind] {
+ [
+ .activeDocument,
+ .debugArea,
+ .clipboard,
+ .senseScope,
+ .projectScope,
+ .webScope,
+ .gitStatus,
+ .gitLog,
+ .file(path: ""),
+ ]
+ }
+
+ var description: String {
+ switch self {
+ case .activeDocument: return "Active Document"
+ case .debugArea: return "Debug Area"
+ case .clipboard: return "Clipboard"
+ case .senseScope: return "Sense Scope"
+ case .projectScope: return "Project Scope"
+ case .webScope: return "Web Scope"
+ case .gitStatus: return "Git Status and Diff"
+ case .gitLog: return "Git Log"
+ case .file: return "File"
+ }
+ }
+}
+
struct EditSendMessageCommandView: View {
@Perception.Bindable var store: StoreOf
+ var attachmentStore: StoreOf
var body: some View {
WithPerceptionTracking {
VStack(alignment: .leading, spacing: 4) {
- Toggle("Extra System Prompt", isOn: $store.useExtraSystemPrompt)
+ Toggle("Extra Context", isOn: $store.useExtraSystemPrompt)
EditableText(text: $store.extraSystemPrompt)
}
.padding(.vertical, 4)
VStack(alignment: .leading, spacing: 4) {
- Text("Prompt")
+ Text("Send immediately")
EditableText(text: $store.prompt)
}
.padding(.vertical, 4)
+
+ CustomCommandAttachmentPickerView(store: attachmentStore)
+ .padding(.vertical, 4)
}
}
}
@@ -146,7 +300,6 @@ struct EditPromptToCodeCommandView: View {
var body: some View {
WithPerceptionTracking {
Toggle("Continuous Mode", isOn: $store.continuousMode)
- Toggle("Generate Description", isOn: $store.generateDescription)
VStack(alignment: .leading, spacing: 4) {
Text("Extra Context")
@@ -155,7 +308,7 @@ struct EditPromptToCodeCommandView: View {
.padding(.vertical, 4)
VStack(alignment: .leading, spacing: 4) {
- Text("Prompt")
+ Text("Instruction")
EditableText(text: $store.prompt)
}
.padding(.vertical, 4)
@@ -165,20 +318,24 @@ struct EditPromptToCodeCommandView: View {
struct EditCustomChatCommandView: View {
@Perception.Bindable var store: StoreOf
+ var attachmentStore: StoreOf
var body: some View {
WithPerceptionTracking {
VStack(alignment: .leading, spacing: 4) {
- Text("System Prompt")
+ Text("Topic")
EditableText(text: $store.systemPrompt)
}
.padding(.vertical, 4)
VStack(alignment: .leading, spacing: 4) {
- Text("Prompt")
+ Text("Send immediately")
EditableText(text: $store.prompt)
}
.padding(.vertical, 4)
+
+ CustomCommandAttachmentPickerView(store: attachmentStore)
+ .padding(.vertical, 4)
}
}
}
@@ -195,8 +352,8 @@ struct EditSingleRoundDialogCommandView: View {
.padding(.vertical, 4)
Picker(selection: $store.overwriteSystemPrompt) {
- Text("Append to Default System Prompt").tag(false)
- Text("Overwrite Default System Prompt").tag(true)
+ Text("Append to default system prompt").tag(false)
+ Text("Overwrite default system prompt").tag(true)
} label: {
Text("Mode")
}
@@ -208,7 +365,7 @@ struct EditSingleRoundDialogCommandView: View {
}
.padding(.vertical, 4)
- Toggle("Receive Reply in Notification", isOn: $store.receiveReplyInNotification)
+ Toggle("Receive response in notification", isOn: $store.receiveReplyInNotification)
Text(
"You will be prompted to grant the app permission to send notifications for the first time."
)
@@ -232,7 +389,9 @@ struct EditCustomCommandView_Preview: PreviewProvider {
prompt: "Hello",
continuousMode: false,
generateDescription: true
- )
+ ),
+ ignoreExistingAttachments: false,
+ attachments: [] as [CustomCommand.Attachment]
)),
reducer: {
EditCustomCommand(
diff --git a/Core/Sources/HostApp/FeatureSettings/Chat/ChatSettingsGeneralSectionView.swift b/Core/Sources/HostApp/FeatureSettings/Chat/ChatSettingsGeneralSectionView.swift
index 83041538..dfb60355 100644
--- a/Core/Sources/HostApp/FeatureSettings/Chat/ChatSettingsGeneralSectionView.swift
+++ b/Core/Sources/HostApp/FeatureSettings/Chat/ChatSettingsGeneralSectionView.swift
@@ -1,6 +1,8 @@
+import Client
import Preferences
import SharedUIComponents
import SwiftUI
+import XPCShared
#if canImport(ProHostApp)
import ProHostApp
@@ -23,6 +25,7 @@ struct ChatSettingsGeneralSectionView: View {
@AppStorage(\.chatModels) var chatModels
@AppStorage(\.embeddingModels) var embeddingModels
@AppStorage(\.wrapCodeInChatCodeBlock) var wrapCodeInCodeBlock
+ @AppStorage(\.chatPanelFloatOnTopOption) var chatPanelFloatOnTopOption
@AppStorage(
\.keepFloatOnTopIfChatPanelAndXcodeOverlaps
) var keepFloatOnTopIfChatPanelAndXcodeOverlaps
@@ -33,7 +36,51 @@ struct ChatSettingsGeneralSectionView: View {
@AppStorage(\.openChatInBrowserURL) var openChatInBrowserURL
@AppStorage(\.openChatInBrowserInInAppBrowser) var openChatInBrowserInInAppBrowser
- init() {}
+ var refreshExtensionExtensionOpenChatHandlerTask: Task?
+
+ @MainActor
+ @Published
+ var openChatOptions = [OpenChatMode]()
+
+ init() {
+ Task { @MainActor in
+ refreshExtensionOpenChatHandlers()
+ }
+ refreshExtensionExtensionOpenChatHandlerTask = Task { [weak self] in
+ let sequence = NotificationCenter.default
+ .notifications(named: NSApplication.didBecomeActiveNotification)
+ for await _ in sequence {
+ guard let self else { return }
+ await MainActor.run {
+ self.refreshExtensionOpenChatHandlers()
+ }
+ }
+ }
+ }
+
+ @MainActor
+ func refreshExtensionOpenChatHandlers() {
+ guard let service = try? getService() else { return }
+ Task { @MainActor in
+ let handlers = try await service
+ .send(requestBody: ExtensionServiceRequests.GetExtensionOpenChatHandlers())
+ openChatOptions = handlers.map {
+ if $0.isBuiltIn {
+ return .builtinExtension(
+ extensionIdentifier: $0.bundleIdentifier,
+ id: $0.id,
+ tabName: $0.tabName
+ )
+ } else {
+ return .externalExtension(
+ extensionIdentifier: $0.bundleIdentifier,
+ id: $0.id,
+ tabName: $0.tabName
+ )
+ }
+ }
+ }
+ }
}
@Environment(\.openURL) var openURL
@@ -58,21 +105,27 @@ struct ChatSettingsGeneralSectionView: View {
Form {
Picker(
"Open Chat Mode",
- selection: $settings.openChatMode
+ selection: .init(get: {
+ settings.openChatMode.value
+ }, set: {
+ settings.openChatMode = .init($0)
+ })
) {
- ForEach(OpenChatMode.allCases, id: \.rawValue) { mode in
+ Text("Open chat panel").tag(OpenChatMode.chatPanel)
+ Text("Open web page in browser").tag(OpenChatMode.browser)
+ ForEach(settings.openChatOptions) { mode in
switch mode {
- case .chatPanel:
- Text("Open chat panel").tag(mode)
- case .browser:
- Text("Open web page in browser").tag(mode)
- case .codeiumChat:
- Text("Open Codeium chat tab").tag(mode)
+ case let .builtinExtension(_, _, name):
+ Text("Open \(name) tab").tag(mode)
+ case let .externalExtension(_, _, name):
+ Text("Open \(name) tab").tag(mode)
+ default:
+ EmptyView()
}
}
}
- if settings.openChatMode == .browser {
+ if settings.openChatMode.value == .browser {
TextField(
"Chat web page URL",
text: $settings.openChatInBrowserURL,
@@ -103,7 +156,7 @@ struct ChatSettingsGeneralSectionView: View {
) {
let allModels = settings.chatModels + [.init(
id: "com.github.copilot",
- name: "GitHub Copilot (poc)",
+ name: "GitHub Copilot Language Server",
format: .openAI,
info: .init()
)]
@@ -200,10 +253,19 @@ struct ChatSettingsGeneralSectionView: View {
Text("7 Messages").tag(7)
Text("9 Messages").tag(9)
Text("11 Messages").tag(11)
+ Text("21 Messages").tag(21)
+ Text("31 Messages").tag(31)
+ Text("41 Messages").tag(41)
+ Text("51 Messages").tag(51)
+ Text("71 Messages").tag(71)
+ Text("91 Messages").tag(91)
+ Text("111 Messages").tag(111)
+ Text("151 Messages").tag(151)
+ Text("201 Messages").tag(201)
}
VStack(alignment: .leading, spacing: 4) {
- Text("Default system prompt")
+ Text("Additional system prompt")
EditableText(text: $settings.defaultChatSystemPrompt)
.lineLimit(6)
}
@@ -237,13 +299,24 @@ struct ChatSettingsGeneralSectionView: View {
CodeHighlightThemePicker(scenario: .chat)
+ Picker("Always-on-top behavior", selection: $settings.chatPanelFloatOnTopOption) {
+ Text("Always").tag(UserDefaultPreferenceKeys.ChatPanelFloatOnTopOption.alwaysOnTop)
+ Text("When Xcode is active")
+ .tag(UserDefaultPreferenceKeys.ChatPanelFloatOnTopOption.onTopWhenXcodeIsActive)
+ Text("Never").tag(UserDefaultPreferenceKeys.ChatPanelFloatOnTopOption.never)
+ }
+
Toggle(isOn: $settings.disableFloatOnTopWhenTheChatPanelIsDetached) {
Text("Disable always-on-top when the chat panel is detached")
- }
+ }.disabled(settings.chatPanelFloatOnTopOption == .never)
Toggle(isOn: $settings.keepFloatOnTopIfChatPanelAndXcodeOverlaps) {
Text("Keep always-on-top if the chat panel and Xcode overlaps and Xcode is active")
- }.disabled(!settings.disableFloatOnTopWhenTheChatPanelIsDetached)
+ }
+ .disabled(
+ !settings.disableFloatOnTopWhenTheChatPanelIsDetached
+ || settings.chatPanelFloatOnTopOption == .never
+ )
}
}
diff --git a/Core/Sources/HostApp/FeatureSettings/Chat/ChatSettingsScopeSectionView.swift b/Core/Sources/HostApp/FeatureSettings/Chat/ChatSettingsScopeSectionView.swift
deleted file mode 100644
index 9e39656b..00000000
--- a/Core/Sources/HostApp/FeatureSettings/Chat/ChatSettingsScopeSectionView.swift
+++ /dev/null
@@ -1,241 +0,0 @@
-import Preferences
-import SharedUIComponents
-import SwiftUI
-
-#if canImport(ProHostApp)
-import ProHostApp
-#endif
-
-struct ChatSettingsScopeSectionView: View {
- class Settings: ObservableObject {
- @AppStorage(\.enableFileScopeByDefaultInChatContext)
- var enableFileScopeByDefaultInChatContext: Bool
- @AppStorage(\.enableCodeScopeByDefaultInChatContext)
- var enableCodeScopeByDefaultInChatContext: Bool
- @AppStorage(\.enableSenseScopeByDefaultInChatContext)
- var enableSenseScopeByDefaultInChatContext: Bool
- @AppStorage(\.enableProjectScopeByDefaultInChatContext)
- var enableProjectScopeByDefaultInChatContext: Bool
- @AppStorage(\.enableWebScopeByDefaultInChatContext)
- var enableWebScopeByDefaultInChatContext: Bool
- @AppStorage(\.preferredChatModelIdForSenseScope)
- var preferredChatModelIdForSenseScope: String
- @AppStorage(\.preferredChatModelIdForProjectScope)
- var preferredChatModelIdForProjectScope: String
- @AppStorage(\.preferredChatModelIdForWebScope)
- var preferredChatModelIdForWebScope: String
- @AppStorage(\.chatModels) var chatModels
- @AppStorage(\.maxFocusedCodeLineCount)
- var maxFocusedCodeLineCount
-
- init() {}
- }
-
- @StateObject var settings = Settings()
-
- var body: some View {
- VStack {
- SubSection(
- title: Text("File Scope"),
- description: "Enable the bot to read the metadata of the editing file."
- ) {
- Form {
- Toggle(isOn: $settings.enableFileScopeByDefaultInChatContext) {
- Text("Enable by default")
- }
- }
- }
-
- SubSection(
- title: Text("Code Scope"),
- description: "Enable the bot to read the code and metadata of the editing file."
- ) {
- Form {
- Toggle(isOn: $settings.enableCodeScopeByDefaultInChatContext) {
- Text("Enable by default")
- }
-
- HStack {
- TextField(text: .init(get: {
- "\(Int(settings.maxFocusedCodeLineCount))"
- }, set: {
- settings.maxFocusedCodeLineCount = Int($0) ?? 0
- })) {
- Text("Max focused code")
- }
- .textFieldStyle(.roundedBorder)
-
- Text("lines")
- }
- }
- }
-
- #if canImport(ProHostApp)
-
- SubSection(
- title: Text("Sense Scope (Experimental)"),
- description: IfFeatureEnabled(\.senseScopeInChat) {
- Text("""
- Enable the bot to access the relevant code \
- of the editing document in the project, third party packages and the SDK.
- """)
- } else: {
- VStack(alignment: .leading) {
- Text("""
- Enable the bot to read the relevant code \
- of the editing document in the SDK, and
- """)
-
- WithFeatureEnabled(\.senseScopeInChat, alignment: .inlineLeading) {
- Text("the project and third party packages.")
- }
- }
- }
- ) {
- Form {
- Toggle(isOn: $settings.enableSenseScopeByDefaultInChatContext) {
- Text("Enable by default")
- }
-
- let allModels = settings.chatModels + [.init(
- id: "com.github.copilot",
- name: "GitHub Copilot (poc)",
- format: .openAI,
- info: .init()
- )]
-
- Picker(
- "Preferred chat model",
- selection: $settings.preferredChatModelIdForSenseScope
- ) {
- Text("Use the default model").tag("")
-
- if !allModels
- .contains(where: {
- $0.id == settings.preferredChatModelIdForSenseScope
- }),
- !settings.preferredChatModelIdForSenseScope.isEmpty
- {
- Text(
- (allModels.first?.name).map { "\($0) (Default)" }
- ?? "No model found"
- )
- .tag(settings.preferredChatModelIdForSenseScope)
- }
-
- ForEach(allModels, id: \.id) { chatModel in
- Text(chatModel.name).tag(chatModel.id)
- }
- }
- }
- }
-
- SubSection(
- title: Text("Project Scope (Experimental)"),
- description: IfFeatureEnabled(\.projectScopeInChat) {
- Text("""
- Enable the bot to search code and texts \
- in the project, third party packages and the SDK.
-
- The current implementation only performs keyword search.
- """)
- } else: {
- VStack(alignment: .leading) {
- Text("""
- Enable the bot to search code and texts \
- in the neighboring files of the editing document, and
- """)
-
- WithFeatureEnabled(\.senseScopeInChat, alignment: .inlineLeading) {
- Text("the project, third party packages and the SDK.")
- }
-
- Text("The current implementation only performs keyword search.")
- }
- }
- ) {
- Form {
- Toggle(isOn: $settings.enableProjectScopeByDefaultInChatContext) {
- Text("Enable by default")
- }
-
- let allModels = settings.chatModels + [.init(
- id: "com.github.copilot",
- name: "GitHub Copilot (poc)",
- format: .openAI,
- info: .init()
- )]
-
- Picker(
- "Preferred chat model",
- selection: $settings.preferredChatModelIdForProjectScope
- ) {
- Text("Use the default model").tag("")
-
- if !allModels
- .contains(where: {
- $0.id == settings.preferredChatModelIdForProjectScope
- }),
- !settings.preferredChatModelIdForProjectScope.isEmpty
- {
- Text(
- (allModels.first?.name).map { "\($0) (Default)" }
- ?? "No Model Found"
- )
- .tag(settings.preferredChatModelIdForProjectScope)
- }
-
- ForEach(allModels, id: \.id) { chatModel in
- Text(chatModel.name).tag(chatModel.id)
- }
- }
- }
- }
-
- #endif
-
- SubSection(
- title: Text("Web Scope"),
- description: "Allow the bot to search on Bing or read a web page. The current implementation requires function calling."
- ) {
- Form {
- Toggle(isOn: $settings.enableWebScopeByDefaultInChatContext) {
- Text("Enable @web scope by default in chat context.")
- }
-
- let allModels = settings.chatModels + [.init(
- id: "com.github.copilot",
- name: "GitHub Copilot (poc)",
- format: .openAI,
- info: .init()
- )]
-
- Picker(
- "Preferred chat model",
- selection: $settings.preferredChatModelIdForWebScope
- ) {
- Text("Use the default model").tag("")
-
- if !allModels
- .contains(where: {
- $0.id == settings.preferredChatModelIdForWebScope
- }),
- !settings.preferredChatModelIdForWebScope.isEmpty
- {
- Text(
- (allModels.first?.name).map { "\($0) (Default)" }
- ?? "No model found"
- )
- .tag(settings.preferredChatModelIdForWebScope)
- }
-
- ForEach(allModels, id: \.id) { chatModel in
- Text(chatModel.name).tag(chatModel.id)
- }
- }
- }
- }
- }
- }
-}
-
diff --git a/Core/Sources/HostApp/FeatureSettings/Chat/ChatSettingsView.swift b/Core/Sources/HostApp/FeatureSettings/Chat/ChatSettingsView.swift
index a4ea5e6e..8540b9d2 100644
--- a/Core/Sources/HostApp/FeatureSettings/Chat/ChatSettingsView.swift
+++ b/Core/Sources/HostApp/FeatureSettings/Chat/ChatSettingsView.swift
@@ -5,7 +5,6 @@ import SwiftUI
struct ChatSettingsView: View {
enum Tab {
case general
- case scopes
}
@State var tabSelection: Tab = .general
@@ -14,7 +13,6 @@ struct ChatSettingsView: View {
VStack(spacing: 0) {
Picker("", selection: $tabSelection) {
Text("General").tag(Tab.general)
- Text("Scopes").tag(Tab.scopes)
}
.pickerStyle(.segmented)
.padding(8)
@@ -27,8 +25,6 @@ struct ChatSettingsView: View {
switch tabSelection {
case .general:
ChatSettingsGeneralSectionView()
- case .scopes:
- ChatSettingsScopeSectionView()
}
}.padding()
}
diff --git a/Core/Sources/HostApp/FeatureSettings/PromptToCodeSettingsView.swift b/Core/Sources/HostApp/FeatureSettings/PromptToCodeSettingsView.swift
index 87403d1d..f9c7f545 100644
--- a/Core/Sources/HostApp/FeatureSettings/PromptToCodeSettingsView.swift
+++ b/Core/Sources/HostApp/FeatureSettings/PromptToCodeSettingsView.swift
@@ -2,10 +2,6 @@ import Preferences
import SharedUIComponents
import SwiftUI
-#if canImport(ProHostApp)
-import ProHostApp
-#endif
-
struct PromptToCodeSettingsView: View {
final class Settings: ObservableObject {
@AppStorage(\.hideCommonPrecedingSpacesInPromptToCode)
@@ -52,36 +48,6 @@ struct PromptToCodeSettingsView: View {
Text(chatModel.name).tag(chatModel.id)
}
}
-
- Picker(
- "Embedding model",
- selection: $settings.promptToCodeEmbeddingModelId
- ) {
- Text("Same as chat feature").tag("")
-
- if !settings.embeddingModels
- .contains(where: { $0.id == settings.promptToCodeEmbeddingModelId }),
- !settings.promptToCodeEmbeddingModelId.isEmpty
- {
- Text(
- (settings.embeddingModels.first?.name).map { "\($0) (Default)" }
- ?? "No model found"
- )
- .tag(settings.promptToCodeEmbeddingModelId)
- }
-
- ForEach(settings.embeddingModels, id: \.id) { embeddingModel in
- Text(embeddingModel.name).tag(embeddingModel.id)
- }
- }
-
- Toggle(isOn: $settings.promptToCodeGenerateDescription) {
- Text("Generate description")
- }
-
- Toggle(isOn: $settings.promptToCodeGenerateDescriptionInUserPreferredLanguage) {
- Text("Generate description in user preferred language")
- }
}
SettingsDivider("UI")
@@ -90,66 +56,17 @@ struct PromptToCodeSettingsView: View {
Toggle(isOn: $settings.hideCommonPrecedingSpaces) {
Text("Hide common preceding spaces")
}
-
+
Toggle(isOn: $settings.wrapCode) {
Text("Wrap code")
}
CodeHighlightThemePicker(scenario: .promptToCode)
-
+
FontPicker(font: $settings.font) {
Text("Font")
}
}
-
- ScopeForm()
- }
- }
-
- struct ScopeForm: View {
- class Settings: ObservableObject {
- @AppStorage(\.enableSenseScopeByDefaultInPromptToCode)
- var enableSenseScopeByDefaultInPromptToCode: Bool
- init() {}
- }
-
- @StateObject var settings = Settings()
-
- var body: some View {
- SettingsDivider("Scopes")
-
- VStack {
- #if canImport(ProHostApp)
-
- SubSection(
- title: Text("Sense Scope (Experimental)"),
- description: IfFeatureEnabled(\.senseScopeInChat) {
- Text("""
- Enable the bot to access the relevant code \
- of the editing document in the project, third party packages and the SDK.
- """)
- } else: {
- VStack(alignment: .leading) {
- Text("""
- Enable the bot to read the relevant code \
- of the editing document in the SDK, and
- """)
-
- WithFeatureEnabled(\.senseScopeInChat, alignment: .inlineLeading) {
- Text("the project and third party packages.")
- }
- }
- }
- ) {
- Form {
- Toggle(isOn: $settings.enableSenseScopeByDefaultInPromptToCode) {
- Text("Enable by default")
- }
- }
- }
-
- #endif
- }
}
}
}
diff --git a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureDisabledLanguageListView.swift b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureDisabledLanguageListView.swift
index 41fa9fb4..6d894cfd 100644
--- a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureDisabledLanguageListView.swift
+++ b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureDisabledLanguageListView.swift
@@ -74,7 +74,7 @@ struct SuggestionFeatureDisabledLanguageListView: View {
if settings.suggestionFeatureDisabledLanguageList.isEmpty {
Text("""
Empty
- Disable the language of a file by right clicking the circular widget.
+ Disable the language of a file by right clicking the indicator widget.
""")
.multilineTextAlignment(.center)
.padding()
diff --git a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureEnabledProjectListView.swift b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureEnabledProjectListView.swift
index f57cd5e4..0cf66ca6 100644
--- a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureEnabledProjectListView.swift
+++ b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureEnabledProjectListView.swift
@@ -82,7 +82,7 @@ struct SuggestionFeatureEnabledProjectListView: View {
Text("""
Empty
Add project with "+" button
- Or right clicking the circular widget
+ Or right clicking the indicator widget
""")
.multilineTextAlignment(.center)
}
diff --git a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsGeneralSectionView.swift b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsGeneralSectionView.swift
index 30bf0650..390c7f98 100644
--- a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsGeneralSectionView.swift
+++ b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsGeneralSectionView.swift
@@ -68,7 +68,7 @@ struct SuggestionSettingsGeneralSectionView: View {
refreshExtensionSuggestionFeatureProviders()
}
refreshExtensionSuggestionFeatureProvidersTask = Task { [weak self] in
- let sequence = await NotificationCenter.default
+ let sequence = NotificationCenter.default
.notifications(named: NSApplication.didBecomeActiveNotification)
for await _ in sequence {
guard let self else { return }
@@ -88,8 +88,6 @@ struct SuggestionSettingsGeneralSectionView: View {
extensionSuggestionFeatureProviderOptions = services.map {
.init(name: $0.name, bundleIdentifier: $0.bundleIdentifier)
}
- print(services.map(\.bundleIdentifier))
- print(suggestionFeatureProvider)
}
}
}
@@ -265,6 +263,8 @@ struct SuggestionSettingsGeneralSectionView: View {
var needControl
@AppStorage(\.acceptSuggestionWithModifierOnlyForSwift)
var onlyForSwift
+ @AppStorage(\.acceptSuggestionLineWithModifierControl)
+ var acceptLineWithControl
}
@StateObject var settings = Settings()
@@ -292,6 +292,12 @@ struct SuggestionSettingsGeneralSectionView: View {
Toggle(isOn: $settings.onlyForSwift) {
Text("Only require modifiers for Swift")
}
+
+ Divider()
+
+ Toggle(isOn: $settings.acceptLineWithControl) {
+ Text("Accept suggestion first line with Control")
+ }
}
.padding()
diff --git a/Core/Sources/HostApp/FeatureSettingsView.swift b/Core/Sources/HostApp/FeatureSettingsView.swift
index 4df4f10c..e8c1e38f 100644
--- a/Core/Sources/HostApp/FeatureSettingsView.swift
+++ b/Core/Sources/HostApp/FeatureSettingsView.swift
@@ -1,6 +1,11 @@
import SwiftUI
+import SharedUIComponents
struct FeatureSettingsView: View {
+ var tabContainer: ExternalTabContainer {
+ ExternalTabContainer.tabContainer(for: "Features")
+ }
+
@State var tag = 0
var body: some View {
@@ -38,20 +43,20 @@ struct FeatureSettingsView: View {
tag: 3,
title: "Xcode",
subtitle: "Xcode related features",
- image: "app"
+ image: "hammer.circle"
)
-
-// #if canImport(ProHostApp)
-// ScrollView {
-// TerminalSettingsView().padding()
-// }
-// .sidebarItem(
-// tag: 3,
-// title: "Terminal",
-// subtitle: "Terminal chat tab",
-// image: "terminal"
-// )
-// #endif
+
+ ForEach(Array(tabContainer.tabs.enumerated()), id: \.1.id) { index, tab in
+ ScrollView {
+ tab.viewBuilder().padding()
+ }
+ .sidebarItem(
+ tag: 4 + index,
+ title: tab.title,
+ subtitle: tab.description,
+ image: tab.image
+ )
+ }
}
}
}
@@ -62,4 +67,3 @@ struct FeatureSettingsView_Previews: PreviewProvider {
.frame(width: 800)
}
}
-
diff --git a/Core/Sources/HostApp/GeneralView.swift b/Core/Sources/HostApp/GeneralView.swift
index 6b08c69b..b69c0127 100644
--- a/Core/Sources/HostApp/GeneralView.swift
+++ b/Core/Sources/HostApp/GeneralView.swift
@@ -280,7 +280,7 @@ struct GeneralSettingsView: View {
}
Toggle(isOn: $settings.hideCircularWidget) {
- Text("Hide circular widget")
+ Text("Hide indicator widget")
}
}.padding()
}
diff --git a/Core/Sources/HostApp/HostApp.swift b/Core/Sources/HostApp/HostApp.swift
index 198e48c2..f2b90303 100644
--- a/Core/Sources/HostApp/HostApp.swift
+++ b/Core/Sources/HostApp/HostApp.swift
@@ -18,6 +18,7 @@ struct HostApp {
var general = General.State()
var chatModelManagement = ChatModelManagement.State()
var embeddingModelManagement = EmbeddingModelManagement.State()
+ var webSearchSettings = WebSearchSettings.State()
}
enum Action {
@@ -25,6 +26,7 @@ struct HostApp {
case general(General.Action)
case chatModelManagement(ChatModelManagement.Action)
case embeddingModelManagement(EmbeddingModelManagement.Action)
+ case webSearchSettings(WebSearchSettings.Action)
}
@Dependency(\.toast) var toast
@@ -34,17 +36,21 @@ struct HostApp {
}
var body: some ReducerOf {
- Scope(state: \.general, action: /Action.general) {
+ Scope(state: \.general, action: \.general) {
General()
}
- Scope(state: \.chatModelManagement, action: /Action.chatModelManagement) {
+ Scope(state: \.chatModelManagement, action: \.chatModelManagement) {
ChatModelManagement()
}
- Scope(state: \.embeddingModelManagement, action: /Action.embeddingModelManagement) {
+ Scope(state: \.embeddingModelManagement, action: \.embeddingModelManagement) {
EmbeddingModelManagement()
}
+
+ Scope(state: \.webSearchSettings, action: \.webSearchSettings) {
+ WebSearchSettings()
+ }
Reduce { _, action in
switch action {
@@ -62,6 +68,9 @@ struct HostApp {
case .embeddingModelManagement:
return .none
+
+ case .webSearchSettings:
+ return .none
}
}
}
diff --git a/Core/Sources/HostApp/ServiceView.swift b/Core/Sources/HostApp/ServiceView.swift
index 2fff4bcf..bf81eb51 100644
--- a/Core/Sources/HostApp/ServiceView.swift
+++ b/Core/Sources/HostApp/ServiceView.swift
@@ -17,7 +17,7 @@ struct ServiceView: View {
subtitle: "Suggestion",
image: "globe"
)
-
+
ScrollView {
CodeiumView().padding()
}.sidebarItem(
@@ -26,7 +26,7 @@ struct ServiceView: View {
subtitle: "Suggestion",
image: "globe"
)
-
+
ChatModelManagementView(store: store.scope(
state: \.chatModelManagement,
action: \.chatModelManagement
@@ -36,7 +36,7 @@ struct ServiceView: View {
subtitle: "Chat, Modification",
image: "globe"
)
-
+
EmbeddingModelManagementView(store: store.scope(
state: \.embeddingModelManagement,
action: \.embeddingModelManagement
@@ -46,16 +46,17 @@ struct ServiceView: View {
subtitle: "Chat, Modification",
image: "globe"
)
-
- ScrollView {
- BingSearchView().padding()
- }.sidebarItem(
+
+ WebSearchView(store: store.scope(
+ state: \.webSearchSettings,
+ action: \.webSearchSettings
+ )).sidebarItem(
tag: 4,
- title: "Bing Search",
- subtitle: "Search Chat Plugin",
+ title: "Web Search",
+ subtitle: "Chat, Modification",
image: "globe"
)
-
+
ScrollView {
OtherSuggestionServicesView().padding()
}.sidebarItem(
diff --git a/Core/Sources/HostApp/SharedComponents/CodeHighlightThemePicker.swift b/Core/Sources/HostApp/SharedComponents/CodeHighlightThemePicker.swift
index 9c20b7dd..1c7151af 100644
--- a/Core/Sources/HostApp/SharedComponents/CodeHighlightThemePicker.swift
+++ b/Core/Sources/HostApp/SharedComponents/CodeHighlightThemePicker.swift
@@ -65,7 +65,7 @@ public struct CodeHighlightThemePicker: View {
}
#Preview {
- @State var sync = false
- return CodeHighlightThemePicker.SyncToggle(sync: $sync)
+ CodeHighlightThemePicker.SyncToggle(sync: .constant(true))
+ CodeHighlightThemePicker.SyncToggle(sync: .constant(false))
}
diff --git a/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift b/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift
index d11095a3..9c81038f 100644
--- a/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift
+++ b/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift
@@ -47,7 +47,7 @@ final class TabToAcceptSuggestion {
init() {
_ = ThreadSafeAccessToXcodeInspector.shared
-
+
hook.add(
.init(
eventsOfInterest: [.keyDown],
@@ -105,117 +105,141 @@ final class TabToAcceptSuggestion {
switch keycode {
case tab:
- guard let fileURL = ThreadSafeAccessToXcodeInspector.shared.activeDocumentURL
- else {
- return .unchanged
- }
+ return handleTab(event.flags)
+ case esc:
+ return handleEsc(event.flags)
+ default:
+ return .unchanged
+ }
+ }
- let language = languageIdentifierFromFileURL(fileURL)
+ func handleTab(_ flags: CGEventFlags) -> CGEventManipulation.Result {
+ Logger.service.info("TabToAcceptSuggestion: Tab")
- func checkKeybinding() -> Bool {
- if event.flags.contains(.maskHelp) { return false }
+ guard let fileURL = ThreadSafeAccessToXcodeInspector.shared.activeDocumentURL
+ else {
+ Logger.service.info("TabToAcceptSuggestion: No active document")
+ return .unchanged
+ }
- let shouldCheckModifiers = if UserDefaults.shared
- .value(for: \.acceptSuggestionWithModifierOnlyForSwift)
- {
- language == .builtIn(.swift)
- } else {
- true
- }
+ let language = languageIdentifierFromFileURL(fileURL)
- if shouldCheckModifiers {
- if event.flags.contains(.maskShift) != UserDefaults.shared
- .value(for: \.acceptSuggestionWithModifierShift)
- {
- return false
- }
- if event.flags.contains(.maskControl) != UserDefaults.shared
- .value(for: \.acceptSuggestionWithModifierControl)
- {
- return false
- }
- if event.flags.contains(.maskAlternate) != UserDefaults.shared
- .value(for: \.acceptSuggestionWithModifierOption)
- {
- return false
- }
- if event.flags.contains(.maskCommand) != UserDefaults.shared
- .value(for: \.acceptSuggestionWithModifierCommand)
- {
- return false
- }
- } else {
- if event.flags.contains(.maskShift) { return false }
- if event.flags.contains(.maskControl) { return false }
- if event.flags.contains(.maskAlternate) { return false }
- if event.flags.contains(.maskCommand) { return false }
- }
+ if flags.contains(.maskHelp) { return .unchanged }
- return true
+ let requiredFlagsToTrigger: CGEventFlags = {
+ var all = CGEventFlags()
+ if UserDefaults.shared.value(for: \.acceptSuggestionWithModifierShift) {
+ all.insert(.maskShift)
}
-
- guard
- checkKeybinding(),
- canTapToAcceptSuggestion
- else {
- return .unchanged
+ if UserDefaults.shared.value(for: \.acceptSuggestionWithModifierControl) {
+ all.insert(.maskControl)
}
-
- guard ThreadSafeAccessToXcodeInspector.shared.activeXcode != nil
- else {
- return .unchanged
+ if UserDefaults.shared.value(for: \.acceptSuggestionWithModifierOption) {
+ all.insert(.maskAlternate)
}
- guard let editor = ThreadSafeAccessToXcodeInspector.shared.focusedEditor
- else {
- return .unchanged
+ if UserDefaults.shared.value(for: \.acceptSuggestionWithModifierCommand) {
+ all.insert(.maskCommand)
}
- guard let filespace = workspacePool.fetchFilespaceIfExisted(fileURL: fileURL)
- else {
- return .unchanged
+ if UserDefaults.shared.value(for: \.acceptSuggestionWithModifierOnlyForSwift) {
+ if language == .builtIn(.swift) {
+ return all
+ } else {
+ return []
+ }
+ } else {
+ return all
}
- guard let presentingSuggestion = filespace.presentingSuggestion
- else {
+ }()
+
+ let flagsToAvoidWhenNotRequired: [CGEventFlags] = [
+ .maskShift, .maskCommand, .maskHelp, .maskSecondaryFn,
+ ]
+
+ guard flags.contains(requiredFlagsToTrigger) else {
+ Logger.service.info("TabToAcceptSuggestion: Modifier not found")
+ return .unchanged
+ }
+
+ for flag in flagsToAvoidWhenNotRequired {
+ if flags.contains(flag), !requiredFlagsToTrigger.contains(flag) {
return .unchanged
}
+ }
- let editorContent = editor.getContent()
+ guard canTapToAcceptSuggestion else {
+ Logger.service.info("TabToAcceptSuggestion: Feature not available")
+ return .unchanged
+ }
- let shouldAcceptSuggestion = Self.checkIfAcceptSuggestion(
- lines: editorContent.lines,
- cursorPosition: editorContent.cursorPosition,
- codeMetadata: filespace.codeMetadata,
- presentingSuggestionText: presentingSuggestion.text
+ guard ThreadSafeAccessToXcodeInspector.shared.activeXcode != nil
+ else {
+ Logger.service.info("TabToAcceptSuggestion: Xcode not found")
+ return .unchanged
+ }
+ guard let editor = ThreadSafeAccessToXcodeInspector.shared.focusedEditor
+ else {
+ Logger.service.info("TabToAcceptSuggestion: No editor found")
+ return .unchanged
+ }
+ guard let filespace = workspacePool.fetchFilespaceIfExisted(fileURL: fileURL)
+ else {
+ Logger.service.info("TabToAcceptSuggestion: No file found")
+ return .unchanged
+ }
+ guard let presentingSuggestion = filespace.presentingSuggestion
+ else {
+ Logger.service.info(
+ "TabToAcceptSuggestion: No presenting found for \(filespace.fileURL.lastPathComponent), found \(filespace.suggestions.count) suggestion, index \(filespace.suggestionIndex)."
)
+ return .unchanged
+ }
- if shouldAcceptSuggestion {
- Task { await commandHandler.acceptSuggestion() }
- return .discarded
+ let editorContent = editor.getContent()
+
+ let shouldAcceptSuggestion = Self.checkIfAcceptSuggestion(
+ lines: editorContent.lines,
+ cursorPosition: editorContent.cursorPosition,
+ codeMetadata: filespace.codeMetadata,
+ presentingSuggestionText: presentingSuggestion.text
+ )
+
+ if shouldAcceptSuggestion {
+ Logger.service.info("TabToAcceptSuggestion: Accept")
+ if flags.contains(.maskControl),
+ !requiredFlagsToTrigger.contains(.maskControl)
+ {
+ Task { await commandHandler.acceptActiveSuggestionLineInGroup(atIndex: nil)
+ }
} else {
- return .unchanged
+ Task { await commandHandler.acceptSuggestion() }
}
- case esc:
- guard
- !event.flags.contains(.maskShift),
- !event.flags.contains(.maskControl),
- !event.flags.contains(.maskAlternate),
- !event.flags.contains(.maskCommand),
- !event.flags.contains(.maskHelp),
- canEscToDismissSuggestion
- else { return .unchanged }
-
- guard
- let fileURL = ThreadSafeAccessToXcodeInspector.shared.activeDocumentURL,
- ThreadSafeAccessToXcodeInspector.shared.activeXcode != nil,
- let filespace = workspacePool.fetchFilespaceIfExisted(fileURL: fileURL),
- filespace.presentingSuggestion != nil
- else { return .unchanged }
-
- Task { await commandHandler.dismissSuggestion() }
return .discarded
- default:
+ } else {
+ Logger.service.info("TabToAcceptSuggestion: Should not accept")
return .unchanged
}
}
+
+ func handleEsc(_ flags: CGEventFlags) -> CGEventManipulation.Result {
+ guard
+ !flags.contains(.maskShift),
+ !flags.contains(.maskControl),
+ !flags.contains(.maskAlternate),
+ !flags.contains(.maskCommand),
+ !flags.contains(.maskHelp),
+ canEscToDismissSuggestion
+ else { return .unchanged }
+
+ guard
+ let fileURL = ThreadSafeAccessToXcodeInspector.shared.activeDocumentURL,
+ ThreadSafeAccessToXcodeInspector.shared.activeXcode != nil,
+ let filespace = workspacePool.fetchFilespaceIfExisted(fileURL: fileURL),
+ filespace.presentingSuggestion != nil
+ else { return .unchanged }
+
+ Task { await commandHandler.dismissSuggestion() }
+ return .discarded
+ }
}
extension TabToAcceptSuggestion {
@@ -248,36 +272,54 @@ extension TabToAcceptSuggestion {
// If entering a tab doesn't invalidate the suggestion, just let the user type the tab.
// else, accept the suggestion and discard the tab.
guard !presentingSuggestionText.hasPrefix(contentAfterTab) else {
+ Logger.service.info("TabToAcceptSuggestion: Space for tab")
return false
}
return true
}
}
-import Combine
-
private class ThreadSafeAccessToXcodeInspector {
static let shared = ThreadSafeAccessToXcodeInspector()
private(set) var activeDocumentURL: URL?
private(set) var activeXcode: AppInstanceInspector?
private(set) var focusedEditor: SourceEditor?
- private var cancellable: Set = []
init() {
- let inspector = XcodeInspector.shared
+ Task { [weak self] in
+ for await _ in NotificationCenter.default
+ .notifications(named: .activeDocumentURLDidChange)
+ {
+ guard let self else { return }
+ self.activeDocumentURL = await XcodeInspector.shared.activeDocumentURL
+ }
+ }
- inspector.$activeDocumentURL.receive(on: DispatchQueue.main).sink { [weak self] newValue in
- self?.activeDocumentURL = newValue
- }.store(in: &cancellable)
+ Task { [weak self] in
+ for await _ in NotificationCenter.default
+ .notifications(named: .activeXcodeDidChange)
+ {
+ guard let self else { return }
+ self.activeXcode = await XcodeInspector.shared.activeXcode
+ }
+ }
- inspector.$activeXcode.receive(on: DispatchQueue.main).sink { [weak self] newValue in
- self?.activeXcode = newValue
- }.store(in: &cancellable)
+ Task { [weak self] in
+ for await _ in NotificationCenter.default
+ .notifications(named: .focusedEditorDidChange)
+ {
+ guard let self else { return }
+ self.focusedEditor = await XcodeInspector.shared.focusedEditor
+ }
+ }
- inspector.$focusedEditor.receive(on: DispatchQueue.main).sink { [weak self] newValue in
- self?.focusedEditor = newValue
- }.store(in: &cancellable)
+ // Initialize current values
+ Task {
+ activeDocumentURL = await XcodeInspector.shared.activeDocumentURL
+ activeXcode = await XcodeInspector.shared.activeApplication
+ focusedEditor = await XcodeInspector.shared.focusedEditor
+ }
}
}
diff --git a/Core/Sources/ChatPlugin/AskChatGPT.swift b/Core/Sources/LegacyChatPlugin/AskChatGPT.swift
similarity index 100%
rename from Core/Sources/ChatPlugin/AskChatGPT.swift
rename to Core/Sources/LegacyChatPlugin/AskChatGPT.swift
diff --git a/Core/Sources/ChatPlugin/CallAIFunction.swift b/Core/Sources/LegacyChatPlugin/CallAIFunction.swift
similarity index 100%
rename from Core/Sources/ChatPlugin/CallAIFunction.swift
rename to Core/Sources/LegacyChatPlugin/CallAIFunction.swift
diff --git a/Core/Sources/LegacyChatPlugin/LegacyChatPlugin.swift b/Core/Sources/LegacyChatPlugin/LegacyChatPlugin.swift
new file mode 100644
index 00000000..49925e6e
--- /dev/null
+++ b/Core/Sources/LegacyChatPlugin/LegacyChatPlugin.swift
@@ -0,0 +1,21 @@
+import Foundation
+import OpenAIService
+
+public protocol LegacyChatPlugin: AnyObject {
+ /// Should be [a-zA-Z0-9]+
+ static var command: String { get }
+ var name: String { get }
+
+ init(inside chatGPTService: any LegacyChatGPTServiceType, delegate: LegacyChatPluginDelegate)
+ func send(content: String, originalMessage: String) async
+ func cancel() async
+ func stopResponding() async
+}
+
+public protocol LegacyChatPluginDelegate: AnyObject {
+ func pluginDidStart(_ plugin: LegacyChatPlugin)
+ func pluginDidEnd(_ plugin: LegacyChatPlugin)
+ func pluginDidStartResponding(_ plugin: LegacyChatPlugin)
+ func pluginDidEndResponding(_ plugin: LegacyChatPlugin)
+ func shouldStartAnotherPlugin(_ type: LegacyChatPlugin.Type, withContent: String)
+}
diff --git a/Core/Sources/ChatPlugin/TerminalChatPlugin.swift b/Core/Sources/LegacyChatPlugin/TerminalChatPlugin.swift
similarity index 92%
rename from Core/Sources/ChatPlugin/TerminalChatPlugin.swift
rename to Core/Sources/LegacyChatPlugin/TerminalChatPlugin.swift
index 9c975bbc..3ac8bd74 100644
--- a/Core/Sources/ChatPlugin/TerminalChatPlugin.swift
+++ b/Core/Sources/LegacyChatPlugin/TerminalChatPlugin.swift
@@ -3,16 +3,16 @@ import OpenAIService
import Terminal
import XcodeInspector
-public actor TerminalChatPlugin: ChatPlugin {
+public actor TerminalChatPlugin: LegacyChatPlugin {
public static var command: String { "run" }
public nonisolated var name: String { "Terminal" }
let chatGPTService: any LegacyChatGPTServiceType
var terminal: TerminalType = Terminal()
var isCancelled = false
- weak var delegate: ChatPluginDelegate?
+ weak var delegate: LegacyChatPluginDelegate?
- public init(inside chatGPTService: any LegacyChatGPTServiceType, delegate: ChatPluginDelegate) {
+ public init(inside chatGPTService: any LegacyChatGPTServiceType, delegate: LegacyChatPluginDelegate) {
self.chatGPTService = chatGPTService
self.delegate = delegate
}
@@ -34,8 +34,8 @@ public actor TerminalChatPlugin: ChatPlugin {
}
do {
- let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL
- let projectURL = await XcodeInspector.shared.safe.realtimeActiveProjectURL
+ let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL
+ let projectURL = XcodeInspector.shared.realtimeActiveProjectURL
var environment = [String: String]()
if let fileURL {
diff --git a/Core/Sources/ChatPlugin/Translate.swift b/Core/Sources/LegacyChatPlugin/Translate.swift
similarity index 100%
rename from Core/Sources/ChatPlugin/Translate.swift
rename to Core/Sources/LegacyChatPlugin/Translate.swift
diff --git a/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift b/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift
index 2cc146bd..25db646f 100644
--- a/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift
+++ b/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift
@@ -1,26 +1,57 @@
import Foundation
+import ModificationBasic
import OpenAIService
import Preferences
import SuggestionBasic
import XcodeInspector
-public final class OpenAIPromptToCodeService: PromptToCodeServiceType {
- var service: (any LegacyChatGPTServiceType)?
+public final class SimpleModificationAgent: ModificationAgent {
+ public func send(_ request: Request) -> AsyncThrowingStream {
+ AsyncThrowingStream { continuation in
+ let task = Task {
+ do {
+ let stream = try await modifyCode(
+ code: request.code,
+ requirement: request.requirement,
+ source: .init(
+ language: request.source.language,
+ documentURL: request.source.documentURL,
+ projectRootURL: request.source.projectRootURL,
+ content: request.source.content,
+ lines: request.source.lines,
+ range: request.range
+ ),
+ isDetached: request.isDetached,
+ extraSystemPrompt: request.extraSystemPrompt,
+ generateDescriptionRequirement: false
+ )
- public init() {}
+ for try await response in stream {
+ continuation.yield(response)
+ }
- public func stopResponding() {
- Task { await service?.stopReceivingMessage() }
+ continuation.finish()
+ } catch {
+ continuation.finish(throwing: error)
+ }
+ }
+
+ continuation.onTermination = { _ in
+ task.cancel()
+ }
+ }
}
- public func modifyCode(
+ public init() {}
+
+ func modifyCode(
code: String,
requirement: String,
source: PromptToCodeSource,
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
@@ -37,7 +68,7 @@ public final class OpenAIPromptToCodeService: PromptToCodeServiceType {
content: source.content,
lines: source.lines,
selections: [source.range],
- cursorPosition: .outOfScope,
+ cursorPosition: .outOfScope,
cursorOffset: -1,
lineAnnotations: []
),
@@ -176,50 +207,64 @@ public final class OpenAIPromptToCodeService: PromptToCodeServiceType {
let configuration =
UserPreferenceChatGPTConfiguration(chatModelKey: \.promptToCodeChatModelId)
.overriding(.init(temperature: 0))
+
let memory = AutoManagedChatGPTMemory(
systemPrompt: systemPrompt,
configuration: configuration,
- functionProvider: NoChatGPTFunctionProvider(),
+ functionProvider: NoChatGPTFunctionProvider(),
maxNumberOfMessages: .max
)
- let chatGPTService = LegacyChatGPTService(
- memory: memory,
- configuration: configuration
+ let chatGPTService = ChatGPTService(
+ configuration: configuration,
+ functionProvider: NoChatGPTFunctionProvider()
)
- service = chatGPTService
+
if let firstMessage {
await memory.mutateHistory { history in
history.append(.init(role: .user, content: firstMessage))
history.append(.init(role: .assistant, content: secondMessage))
+ history.append(.init(role: .user, content: requirement))
}
}
- let stream = try await chatGPTService.send(content: requirement)
+ 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()
+ }
}
}
}
// MAKR: - Internal
-extension OpenAIPromptToCodeService {
+extension SimpleModificationAgent {
func extractCodeAndDescription(from content: String)
-> (code: String, description: String)
{
diff --git a/Core/Sources/PromptToCodeService/PreviewPromptToCodeService.swift b/Core/Sources/PromptToCodeService/PreviewPromptToCodeService.swift
index c6062ec7..ac0fd4df 100644
--- a/Core/Sources/PromptToCodeService/PreviewPromptToCodeService.swift
+++ b/Core/Sources/PromptToCodeService/PreviewPromptToCodeService.swift
@@ -1,10 +1,47 @@
import Foundation
+import ModificationBasic
import SuggestionBasic
-public final class PreviewPromptToCodeService: PromptToCodeServiceType {
+public final class PreviewModificationAgent: ModificationAgent {
+ public func send(_ request: Request) -> AsyncThrowingStream {
+ AsyncThrowingStream { continuation in
+ let task = Task {
+ do {
+ let stream = try await modifyCode(
+ code: request.code,
+ requirement: request.requirement,
+ source: .init(
+ language: request.source.language,
+ documentURL: request.source.documentURL,
+ projectRootURL: request.source.projectRootURL,
+ content: request.source.content,
+ lines: request.source.lines,
+ range: request.range
+ ),
+ isDetached: request.isDetached,
+ extraSystemPrompt: request.extraSystemPrompt,
+ generateDescriptionRequirement: false
+ )
+
+ for try await (code, description) in stream {
+ continuation.yield(.code(code))
+ }
+
+ continuation.finish()
+ } catch {
+ continuation.finish(throwing: error)
+ }
+ }
+
+ continuation.onTermination = { _ in
+ task.cancel()
+ }
+ }
+ }
+
public init() {}
- public func modifyCode(
+ func modifyCode(
code: String,
requirement: String,
source: PromptToCodeSource,
diff --git a/Core/Sources/PromptToCodeService/PromptToCodeServiceType.swift b/Core/Sources/PromptToCodeService/PromptToCodeServiceType.swift
index 07acdb87..3e0cd400 100644
--- a/Core/Sources/PromptToCodeService/PromptToCodeServiceType.swift
+++ b/Core/Sources/PromptToCodeService/PromptToCodeServiceType.swift
@@ -2,19 +2,6 @@ import Dependencies
import Foundation
import SuggestionBasic
-public protocol PromptToCodeServiceType {
- func modifyCode(
- code: String,
- requirement: String,
- source: PromptToCodeSource,
- isDetached: Bool,
- extraSystemPrompt: String?,
- generateDescriptionRequirement: Bool?
- ) async throws -> AsyncThrowingStream<(code: String, description: String), Error>
-
- func stopResponding()
-}
-
public struct PromptToCodeSource {
public var language: CodeLanguage
public var documentURL: URL
@@ -39,78 +26,3 @@ public struct PromptToCodeSource {
self.range = range
}
}
-
-public struct PromptToCodeServiceDependencyKey: DependencyKey {
- public static let liveValue: PromptToCodeServiceType = PreviewPromptToCodeService()
- public static let previewValue: PromptToCodeServiceType = PreviewPromptToCodeService()
-}
-
-public extension DependencyValues {
- var promptToCodeService: PromptToCodeServiceType {
- get { self[PromptToCodeServiceDependencyKey.self] }
- set { self[PromptToCodeServiceDependencyKey.self] = newValue }
- }
-
- var promptToCodeServiceFactory: () -> PromptToCodeServiceType {
- get { self[PromptToCodeServiceFactoryDependencyKey.self] }
- set { self[PromptToCodeServiceFactoryDependencyKey.self] = newValue }
- }
-}
-
-#if canImport(ContextAwarePromptToCodeService)
-
-import ContextAwarePromptToCodeService
-
-extension ContextAwarePromptToCodeService: PromptToCodeServiceType {
- public func stopResponding() {}
-
- public func modifyCode(
- code: String,
- requirement: String,
- source: PromptToCodeSource,
- isDetached: Bool,
- extraSystemPrompt: String?,
- generateDescriptionRequirement: Bool?
- ) async throws -> AsyncThrowingStream<(code: String, description: String), Error> {
- try await modifyCode(
- code: code,
- requirement: requirement,
- source: ContextAwarePromptToCodeService.Source(
- language: source.language,
- documentURL: source.documentURL,
- projectRootURL: source.projectRootURL,
- content: source.content,
- lines: source.lines,
- range: source.range
- ),
- isDetached: isDetached,
- extraSystemPrompt: extraSystemPrompt,
- generateDescriptionRequirement: generateDescriptionRequirement
- )
- }
-}
-
-public struct PromptToCodeServiceFactoryDependencyKey: DependencyKey {
- public static let liveValue: () -> PromptToCodeServiceType = {
- ContextAwarePromptToCodeService()
- }
-
- public static let previewValue: () -> PromptToCodeServiceType = {
- PreviewPromptToCodeService()
- }
-}
-
-#else
-
-public struct PromptToCodeServiceFactoryDependencyKey: DependencyKey {
- public static let liveValue: () -> PromptToCodeServiceType = {
- OpenAIPromptToCodeService()
- }
-
- public static let previewValue: () -> PromptToCodeServiceType = {
- PreviewPromptToCodeService()
- }
-}
-
-#endif
-
diff --git a/Core/Sources/Service/GUI/ChatTabFactory.swift b/Core/Sources/Service/GUI/ChatTabFactory.swift
index dc8f3271..fae16330 100644
--- a/Core/Sources/Service/GUI/ChatTabFactory.swift
+++ b/Core/Sources/Service/GUI/ChatTabFactory.swift
@@ -8,26 +8,20 @@ import SuggestionBasic
import SuggestionWidget
import XcodeInspector
-#if canImport(ProChatTabs)
-import ProChatTabs
-#endif
-
enum ChatTabFactory {
static func chatTabBuilderCollection() -> [ChatTabBuilderCollection] {
- #if canImport(ProChatTabs)
- _ = lazyLoadDependency
- let collection = [
- folderIfNeeded(ChatGPTChatTab.chatBuilders(), title: ChatGPTChatTab.name),
- folderIfNeeded(BrowserChatTab.chatBuilders(), title: BrowserChatTab.name),
- folderIfNeeded(TerminalChatTab.chatBuilders(), title: TerminalChatTab.name),
- ]
- #else
- let collection = [
- folderIfNeeded(ChatGPTChatTab.chatBuilders(), title: ChatGPTChatTab.name),
- ]
- #endif
-
- return collection.compactMap { $0 } + chatTabsFromExtensions()
+ let chatGPTChatTab = folderIfNeeded(
+ ChatGPTChatTab.chatBuilders(),
+ title: ChatGPTChatTab.name
+ )
+
+ let (defaultChatTab, othersChatTabs) = chatTabsFromExtensions()
+
+ if let defaultChatTab {
+ return [defaultChatTab] + othersChatTabs + [chatGPTChatTab].compactMap(\.self)
+ } else {
+ return [chatGPTChatTab].compactMap(\.self) + othersChatTabs
+ }
}
private static func folderIfNeeded(
@@ -41,70 +35,23 @@ enum ChatTabFactory {
return nil
}
- static func chatTabsFromExtensions() -> [ChatTabBuilderCollection] {
+ static func chatTabsFromExtensions()
+ -> (default: ChatTabBuilderCollection?, others: [ChatTabBuilderCollection])
+ {
let extensions = BuiltinExtensionManager.shared.extensions
let chatTabTypes = extensions.flatMap(\.chatTabTypes)
- return chatTabTypes.compactMap { folderIfNeeded($0.chatBuilders(), title: $0.name) }
+ var defaultChatTab: ChatTabBuilderCollection?
+ var otherChatTabs = [ChatTabBuilderCollection]()
+ for chatTabType in chatTabTypes {
+ if chatTabType.isDefaultChatTabReplacement {
+ defaultChatTab = folderIfNeeded(chatTabType.chatBuilders(), title: chatTabType.name)
+ } else if let tab = folderIfNeeded(
+ chatTabType.chatBuilders(),
+ title: chatTabType.name
+ ) {
+ otherChatTabs.append(tab)
+ }
+ }
+ return (defaultChatTab, otherChatTabs)
}
}
-
-#if canImport(ProChatTabs)
-let lazyLoadDependency: () = {
- BrowserChatTab.externalDependency = .init(
- handleCustomCommand: { command, prompt in
- switch command.feature {
- case let .chatWithSelection(extraSystemPrompt, _, useExtraSystemPrompt):
- let service = ChatService()
- return try await service.processMessage(
- systemPrompt: nil,
- extraSystemPrompt: (useExtraSystemPrompt ?? false) ? extraSystemPrompt :
- nil,
- prompt: prompt
- )
- case let .customChat(systemPrompt, _):
- let service = ChatService()
- return try await service.processMessage(
- systemPrompt: systemPrompt,
- extraSystemPrompt: nil,
- prompt: prompt
- )
- case let .singleRoundDialog(
- systemPrompt,
- overwriteSystemPrompt,
- _,
- _
- ):
- let service = ChatService()
- return try await service.handleSingleRoundDialogCommand(
- systemPrompt: systemPrompt,
- overwriteSystemPrompt: overwriteSystemPrompt ?? false,
- prompt: prompt
- )
- case let .promptToCode(extraSystemPrompt, instruction, _, _):
- let service = OpenAIPromptToCodeService()
-
- let result = try await service.modifyCode(
- code: prompt,
- requirement: instruction ?? "Modify content.",
- source: .init(
- language: .plaintext,
- documentURL: .init(fileURLWithPath: "/"),
- projectRootURL: .init(fileURLWithPath: "/"),
- content: prompt,
- lines: prompt.breakLines(),
- range: .outOfScope
- ),
- isDetached: true,
- extraSystemPrompt: extraSystemPrompt,
- generateDescriptionRequirement: false
- )
- var code = ""
- for try await (newCode, _) in result {
- code = newCode
- }
- return code
- }
- }
- )
-}()
-#endif
diff --git a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift
index ff3fa4ab..dfbd719a 100644
--- a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift
+++ b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift
@@ -6,14 +6,11 @@ import ChatGPTChatTab
import ChatTab
import ComposableArchitecture
import Dependencies
+import Logger
import Preferences
import SuggestionBasic
import SuggestionWidget
-#if canImport(ProChatTabs)
-import ProChatTabs
-#endif
-
#if canImport(ChatTabPersistent)
import ChatTabPersistent
#endif
@@ -106,7 +103,7 @@ struct GUI {
chatTabPool.removeTab(of: id)
}
- case let .chatTab(_, .openNewTab(builder)):
+ case let .chatTab(.element(_, .openNewTab(builder))):
return .run { send in
if let (_, chatTabInfo) = await chatTabPool
.createTab(from: builder.chatTabBuilder)
@@ -188,33 +185,28 @@ struct GUI {
)
}
}
-
- case let .sendCustomCommandToActiveChat(command):
- @Sendable func stopAndHandleCommand(_ tab: ChatGPTChatTab) async {
- if tab.service.isReceivingMessage {
- await tab.service.stopReceivingMessage()
- }
- try? await tab.service.handleCustomCommand(command)
- }
+ case let .sendCustomCommandToActiveChat(command):
if let info = state.chatTabGroup.selectedTabInfo,
- let activeTab = chatTabPool.getTab(of: info.id) as? ChatGPTChatTab
+ let tab = chatTabPool.getTab(of: info.id),
+ tab.handleCustomCommand(command)
{
return .run { send in
await send(.openChatPanel(forceDetach: false, activateThisApp: false))
- await stopAndHandleCommand(activeTab)
}
}
- if let info = state.chatTabGroup.tabInfo.first(where: {
- chatTabPool.getTab(of: $0.id) is ChatGPTChatTab
- }),
- let chatTab = chatTabPool.getTab(of: info.id) as? ChatGPTChatTab
- {
- state.chatTabGroup.selectedTabId = chatTab.id
- return .run { send in
- await send(.openChatPanel(forceDetach: false, activateThisApp: false))
- await stopAndHandleCommand(chatTab)
+ for info in state.chatTabGroup.tabInfo {
+ if let chatTab = chatTabPool.getTab(of: info.id),
+ chatTab.handleCustomCommand(command)
+ {
+ state.chatTabGroup.selectedTabId = chatTab.id
+ return .run { send in
+ await send(.openChatPanel(
+ forceDetach: false,
+ activateThisApp: false
+ ))
+ }
}
}
@@ -223,9 +215,7 @@ struct GUI {
else { return }
await send(.suggestionWidget(.chatPanel(.appendAndSelectTab(chatTabInfo))))
await send(.openChatPanel(forceDetach: false, activateThisApp: false))
- if let chatTab = chatTab as? ChatGPTChatTab {
- await stopAndHandleCommand(chatTab)
- }
+ _ = chatTab.handleCustomCommand(command)
}
case .toggleWidgetsHotkeyPressed:
@@ -233,7 +223,7 @@ struct GUI {
await send(.suggestionWidget(.circularWidget(.widgetClicked)))
}
- case let .suggestionWidget(.chatPanel(.chatTab(id, .tabContentUpdated))):
+ case let .suggestionWidget(.chatPanel(.chatTab(.element(id, .tabContentUpdated)))):
#if canImport(ChatTabPersistent)
// when a tab is updated, persist it.
return .run { send in
@@ -298,7 +288,7 @@ public final class GraphicalUserInterfaceController {
dependencies.suggestionWidgetUserDefaultsObservers = .init()
dependencies.chatTabPool = chatTabPool
dependencies.chatTabBuilderCollection = ChatTabFactory.chatTabBuilderCollection
-
+
#if canImport(ChatTabPersistent) && canImport(ProChatTabs)
dependencies.restoreChatTabInPool = {
await chatTabPool.restore($0)
@@ -329,16 +319,25 @@ public final class GraphicalUserInterfaceController {
state.chatTabGroup.tabInfo[id: id] ?? .init(id: id, title: "")
},
action: { childAction in
- .suggestionWidget(.chatPanel(.chatTab(id: id, action: childAction)))
+ .suggestionWidget(.chatPanel(.chatTab(.element(
+ id: id,
+ action: childAction
+ ))))
}
)
}
suggestionDependency.suggestionWidgetDataSource = widgetDataSource
- suggestionDependency.onOpenChatClicked = { [weak self] in
- Task { [weak self] in
- await self?.store.send(.createAndSwitchToChatGPTChatTabIfNeeded).finish()
- self?.store.send(.openChatPanel(forceDetach: false, activateThisApp: true))
+ suggestionDependency.onOpenChatClicked = {
+ Task {
+ PseudoCommandHandler().openChat(forceDetach: false, activateThisApp: true)
+ }
+ }
+ suggestionDependency.onOpenModificationButtonClicked = {
+ Task {
+ guard let content = await PseudoCommandHandler().getEditorContent(sourceEditor: nil)
+ else { return }
+ _ = try await WindowBaseCommandHandler().promptToCode(editor: content)
}
}
suggestionDependency.onCustomCommandClicked = { command in
@@ -367,7 +366,7 @@ extension ChatTabPool {
let id = id
let info = ChatTabInfo(id: id, title: "")
guard let chatTap = await builder.build(store: createStore(id)) else { return nil }
- setTab(chatTap)
+ setTab(chatTap, forId: id)
return (chatTap, info)
}
@@ -377,33 +376,40 @@ extension ChatTabPool {
) async -> (any ChatTab, ChatTabInfo)? {
let id = UUID().uuidString
let info = ChatTabInfo(id: id, title: "")
- let builder = kind?.builder ?? ChatGPTChatTab.defaultBuilder()
+ let builder = kind?.builder ?? {
+ for ext in BuiltinExtensionManager.shared.extensions {
+ guard let tab = ext.chatTabTypes.first(where: { $0.isDefaultChatTabReplacement })
+ else { continue }
+ return tab.defaultChatBuilder()
+ }
+ return ChatGPTChatTab.defaultBuilder()
+ }()
guard let chatTap = await builder.build(store: createStore(id)) else { return nil }
- setTab(chatTap)
+ setTab(chatTap, forId: id)
return (chatTap, info)
}
- #if canImport(ChatTabPersistent) && canImport(ProChatTabs)
+ #if canImport(ChatTabPersistent)
@MainActor
func restore(
_ data: ChatTabPersistent.RestorableTabData
) async -> (any ChatTab, ChatTabInfo)? {
switch data.name {
case ChatGPTChatTab.name:
- guard let builder = try? await ChatGPTChatTab.restore(from: data.data) else { break }
- return await createTab(id: data.id, from: builder)
- case BrowserChatTab.name:
- guard let builder = try? BrowserChatTab.restore(from: data.data) else { break }
- return await createTab(id: data.id, from: builder)
- case TerminalChatTab.name:
- guard let builder = try? await TerminalChatTab.restore(from: data.data) else { break }
+ guard let builder = try? await ChatGPTChatTab.restore(from: data.data)
+ else { fallthrough }
return await createTab(id: data.id, from: builder)
default:
let chatTabTypes = BuiltinExtensionManager.shared.extensions.flatMap(\.chatTabTypes)
for type in chatTabTypes {
if type.name == data.name {
- guard let builder = try? await type.restore(from: data.data) else { break }
- return await createTab(id: data.id, from: builder)
+ do {
+ let builder = try await type.restore(from: data.data)
+ return await createTab(id: data.id, from: builder)
+ } catch {
+ Logger.service.error("Failed to restore chat tab \(data.name): \(error)")
+ break
+ }
}
}
}
diff --git a/Core/Sources/Service/GlobalShortcutManager.swift b/Core/Sources/Service/GlobalShortcutManager.swift
index cc799fc0..a3bb32b4 100644
--- a/Core/Sources/Service/GlobalShortcutManager.swift
+++ b/Core/Sources/Service/GlobalShortcutManager.swift
@@ -11,7 +11,7 @@ extension KeyboardShortcuts.Name {
@MainActor
final class GlobalShortcutManager {
let guiController: GraphicalUserInterfaceController
- private var cancellable = Set()
+ private var activeAppChangeTask: Task?
nonisolated init(guiController: GraphicalUserInterfaceController) {
self.guiController = guiController
@@ -34,22 +34,30 @@ final class GlobalShortcutManager {
}
}
- XcodeInspector.shared.$activeApplication.sink { app in
- if !UserDefaults.shared.value(for: \.showHideWidgetShortcutGlobally) {
- let shouldBeEnabled = if let app, app.isXcode || app.isExtensionService {
- true
+ activeAppChangeTask?.cancel()
+ activeAppChangeTask = Task.detached { [weak self] in
+ let notifications = NotificationCenter.default
+ .notifications(named: .activeApplicationDidChange)
+ for await _ in notifications {
+ guard let self else { return }
+ try Task.checkCancellation()
+ if !UserDefaults.shared.value(for: \.showHideWidgetShortcutGlobally) {
+ let app = await XcodeInspector.shared.activeApplication
+ let shouldBeEnabled = if let app, app.isXcode || app.isExtensionService {
+ true
+ } else {
+ false
+ }
+ if shouldBeEnabled {
+ await self.setupShortcutIfNeeded()
+ } else {
+ await self.removeShortcutIfNeeded()
+ }
} else {
- false
+ await self.setupShortcutIfNeeded()
}
- if shouldBeEnabled {
- self.setupShortcutIfNeeded()
- } else {
- self.removeShortcutIfNeeded()
- }
- } else {
- self.setupShortcutIfNeeded()
}
- }.store(in: &cancellable)
+ }
}
func setupShortcutIfNeeded() {
diff --git a/Core/Sources/Service/RealtimeSuggestionController.swift b/Core/Sources/Service/RealtimeSuggestionController.swift
index 2d00c960..39770260 100644
--- a/Core/Sources/Service/RealtimeSuggestionController.swift
+++ b/Core/Sources/Service/RealtimeSuggestionController.swift
@@ -2,7 +2,6 @@ import ActiveApplicationMonitor
import AppKit
import AsyncAlgorithms
import AXExtension
-import Combine
import Foundation
import Logger
import Preferences
@@ -11,7 +10,7 @@ import Workspace
import XcodeInspector
public actor RealtimeSuggestionController {
- private var cancellable: Set = []
+ private var xcodeChangeObservationTask: Task?
private var inflightPrefetchTask: Task?
private var editorObservationTask: Task?
private var sourceEditor: SourceEditor?
@@ -19,7 +18,6 @@ public actor RealtimeSuggestionController {
init() {}
deinit {
- cancellable.forEach { $0.cancel() }
inflightPrefetchTask?.cancel()
editorObservationTask?.cancel()
}
@@ -30,16 +28,18 @@ public actor RealtimeSuggestionController {
}
private func observeXcodeChange() {
- cancellable.forEach { $0.cancel() }
+ xcodeChangeObservationTask?.cancel()
- XcodeInspector.shared.$focusedEditor
- .sink { [weak self] editor in
+ xcodeChangeObservationTask = Task { [weak self] in
+ for await _ in NotificationCenter.default
+ .notifications(named: .focusedEditorDidChange)
+ {
guard let self else { return }
- Task {
- guard let editor else { return }
- await self.handleFocusElementChange(editor)
- }
- }.store(in: &cancellable)
+ try Task.checkCancellation()
+ guard let editor = await XcodeInspector.shared.focusedEditor else { continue }
+ await self.handleFocusElementChange(editor)
+ }
+ }
}
private func handleFocusElementChange(_ sourceEditor: SourceEditor) {
@@ -51,7 +51,7 @@ public actor RealtimeSuggestionController {
editorObservationTask = nil
editorObservationTask = Task { [weak self] in
- if let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL {
+ if let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL {
await PseudoCommandHandler().invalidateRealtimeSuggestionsIfNeeded(
fileURL: fileURL,
sourceEditor: sourceEditor
@@ -86,7 +86,7 @@ public actor RealtimeSuggestionController {
}
group.addTask {
let handler = {
- guard let fileURL = await XcodeInspector.shared.safe.activeDocumentURL
+ guard let fileURL = await XcodeInspector.shared.activeDocumentURL
else { return }
await PseudoCommandHandler().invalidateRealtimeSuggestionsIfNeeded(
fileURL: fileURL,
@@ -113,7 +113,8 @@ public actor RealtimeSuggestionController {
Task { @WorkspaceActor in // Get cache ready for real-time suggestions.
guard UserDefaults.shared.value(for: \.preCacheOnFileOpen) else { return }
- guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL
+ guard await XcodeInspector.shared.activeApplication?.isXcode ?? false else { return }
+ guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL
else { return }
let (_, filespace) = try await Service.shared.workspacePool
.fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL)
@@ -123,7 +124,7 @@ public actor RealtimeSuggestionController {
// avoid the command get called twice
filespace.codeMetadata.uti = ""
do {
- try await XcodeInspector.shared.safe.latestActiveXcode?
+ try await XcodeInspector.shared.latestActiveXcode?
.triggerCopilotCommand(name: "Prepare for Real-time Suggestions")
} catch {
if filespace.codeMetadata.uti?.isEmpty ?? true {
@@ -147,7 +148,7 @@ public actor RealtimeSuggestionController {
else { return }
if UserDefaults.shared.value(for: \.disableSuggestionFeatureGlobally),
- let fileURL = await XcodeInspector.shared.safe.activeDocumentURL,
+ let fileURL = await XcodeInspector.shared.activeDocumentURL,
let (workspace, _) = try? await Service.shared.workspacePool
.fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL)
{
@@ -184,7 +185,7 @@ public actor RealtimeSuggestionController {
}
func notifyEditingFileChange(editor: AXUIElement) async {
- guard let fileURL = await XcodeInspector.shared.safe.activeDocumentURL,
+ guard let fileURL = await XcodeInspector.shared.activeDocumentURL,
let (workspace, _) = try? await Service.shared.workspacePool
.fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL)
else { return }
diff --git a/Core/Sources/Service/ScheduledCleaner.swift b/Core/Sources/Service/ScheduledCleaner.swift
index 0475baf9..b011bd78 100644
--- a/Core/Sources/Service/ScheduledCleaner.swift
+++ b/Core/Sources/Service/ScheduledCleaner.swift
@@ -34,7 +34,7 @@ public final class ScheduledCleaner {
func cleanUp() async {
guard let service else { return }
- let workspaceInfos = XcodeInspector.shared.xcodes.reduce(
+ let workspaceInfos = await XcodeInspector.shared.xcodes.reduce(
into: [
XcodeAppInstanceInspector.WorkspaceIdentifier:
XcodeAppInstanceInspector.WorkspaceInfo
diff --git a/Core/Sources/Service/Service.swift b/Core/Sources/Service/Service.swift
index ee5be58b..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,20 +56,22 @@ public final class Service {
realtimeSuggestionController = .init()
scheduledCleaner = .init()
- let guiController = GraphicalUserInterfaceController()
- self.guiController = guiController
- globalShortcutManager = .init(guiController: guiController)
- keyBindingManager = .init()
+ overlayWindowController = .init()
#if canImport(ProService)
proService = ProService()
#endif
- BuiltinExtensionManager.shared.setupExtensions([
+ BuiltinExtensionManager.shared.addExtensions([
GitHubCopilotExtension(workspacePool: workspacePool),
CodeiumExtension(workspacePool: workspacePool),
])
+ let guiController = GraphicalUserInterfaceController()
+ self.guiController = guiController
+ globalShortcutManager = .init(guiController: guiController)
+ keyBindingManager = .init()
+
workspacePool.registerPlugin {
SuggestionServiceWorkspacePlugin(workspace: $0) { SuggestionService.service() }
}
@@ -93,22 +97,25 @@ public final class Service {
#if canImport(ProService)
proService.start()
#endif
+ overlayWindowController.start()
DependencyUpdater().update()
globalShortcutManager.start()
keyBindingManager.start()
- Task {
- await XcodeInspector.shared.safe.$activeDocumentURL
- .removeDuplicates()
- .filter { $0 != .init(fileURLWithPath: "/") }
- .compactMap { $0 }
- .sink { fileURL in
- Task {
- @Dependency(\.workspacePool) var workspacePool
- return try await workspacePool
- .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL)
- }
- }.store(in: &cancellable)
+ Task.detached { [weak self] in
+ let notifications = NotificationCenter.default
+ .notifications(named: .activeDocumentURLDidChange)
+ var previousURL: URL?
+ for await _ in notifications {
+ guard self != nil else { return }
+ let url = await XcodeInspector.shared.activeDocumentURL
+ if let url, url != previousURL, url != .init(fileURLWithPath: "/") {
+ previousURL = url
+ @Dependency(\.workspacePool) var workspacePool
+ _ = try await workspacePool
+ .fetchOrCreateWorkspaceAndFilespace(fileURL: url)
+ }
+ }
}
}
@@ -137,6 +144,38 @@ public extension Service {
reply: reply
)
#endif
+
+ try ExtensionServiceRequests.GetExtensionOpenChatHandlers.handle(
+ endpoint: endpoint,
+ requestBody: requestBody,
+ reply: reply
+ ) { _ in
+ BuiltinExtensionManager.shared.extensions.reduce(into: []) { result, ext in
+ let tabs = ext.chatTabTypes
+ for tab in tabs {
+ if tab.canHandleOpenChatCommand {
+ result.append(.init(
+ bundleIdentifier: ext.extensionIdentifier,
+ id: tab.name,
+ tabName: tab.name,
+ isBuiltIn: true
+ ))
+ }
+ }
+ }
+ }
+
+ try ExtensionServiceRequests.GetSuggestionLineAcceptedCode.handle(
+ endpoint: endpoint,
+ requestBody: requestBody,
+ reply: reply
+ ) { request in
+ let editor = request.editorContent
+ let handler = WindowBaseCommandHandler()
+ let updatedContent = try? await handler
+ .acceptSuggestionLine(editor: editor)
+ return updatedContent
+ }
} catch is XPCRequestHandlerHitError {
return
} catch {
diff --git a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift
index 8818df6a..c1d38d78 100644
--- a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift
+++ b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift
@@ -1,14 +1,19 @@
import ActiveApplicationMonitor
import AppKit
+import BuiltinExtension
import CodeiumService
import CommandHandler
+import ComposableArchitecture
import enum CopilotForXcodeKit.SuggestionServiceError
import Dependencies
import Logger
+import ModificationBasic
import PlusFeatureFlag
import Preferences
+import PromptToCodeCustomization
import SuggestionBasic
import SuggestionInjector
+import Terminal
import Toast
import Workspace
import WorkspaceSuggestionService
@@ -182,7 +187,7 @@ struct PseudoCommandHandler: CommandHandler {
}
}() else {
do {
- try await XcodeInspector.shared.safe.latestActiveXcode?
+ try await XcodeInspector.shared.latestActiveXcode?
.triggerCopilotCommand(name: command.name)
} catch {
let presenter = PresentInWindowSuggestionPresenter()
@@ -200,17 +205,17 @@ struct PseudoCommandHandler: CommandHandler {
}
}
- func acceptPromptToCode() async {
+ func acceptModification() async {
do {
if UserDefaults.shared.value(for: \.alwaysAcceptSuggestionWithAccessibilityAPI) {
throw CancellationError()
}
do {
- try await XcodeInspector.shared.safe.latestActiveXcode?
+ try await XcodeInspector.shared.latestActiveXcode?
.triggerCopilotCommand(name: "Accept Modification")
} catch {
do {
- try await XcodeInspector.shared.safe.latestActiveXcode?
+ try await XcodeInspector.shared.latestActiveXcode?
.triggerCopilotCommand(name: "Accept Prompt to Code")
} catch {
let last = Self.lastTimeCommandFailedToTriggerWithAccessibilityAPI
@@ -218,12 +223,12 @@ struct PseudoCommandHandler: CommandHandler {
if now.timeIntervalSince(last) > 60 * 60 {
Self.lastTimeCommandFailedToTriggerWithAccessibilityAPI = now
toast.toast(content: """
- The app is using a fallback solution to accept suggestions. \
- For better experience, please restart Xcode to re-activate the Copilot \
- menu item.
- """, type: .warning)
+ The app is using a fallback solution to accept suggestions. \
+ For better experience, please restart Xcode to re-activate the Copilot \
+ menu item.
+ """, type: .warning, duration: 10)
}
-
+
throw error
}
}
@@ -267,13 +272,85 @@ struct PseudoCommandHandler: CommandHandler {
}
}
+ func presentModification(state: Shared) async {
+ let store = await Service.shared.guiController.store
+ await store.send(.promptToCodeGroup(.createPromptToCode(.init(
+ promptToCodeState: state,
+ instruction: nil,
+ commandName: nil,
+ isContinuous: false
+ ), sendImmediately: false)))
+ }
+
+ func acceptActiveSuggestionLineInGroup(atIndex index: Int?) async {
+ do {
+ if UserDefaults.shared.value(for: \.alwaysAcceptSuggestionWithAccessibilityAPI) {
+ throw CancellationError()
+ }
+ do {
+ try await XcodeInspector.shared.latestActiveXcode?
+ .triggerCopilotCommand(name: "Accept Suggestion Line")
+ } catch {
+ let last = Self.lastTimeCommandFailedToTriggerWithAccessibilityAPI
+ let now = Date()
+ if now.timeIntervalSince(last) > 60 * 60 {
+ Self.lastTimeCommandFailedToTriggerWithAccessibilityAPI = now
+ toast.toast(content: """
+ The app is using a fallback solution to accept suggestions. \
+ For better experience, please restart Xcode to re-activate the Copilot \
+ menu item.
+ """, type: .warning, duration: 10)
+ }
+
+ throw error
+ }
+ } catch {
+ guard let xcode = ActiveApplicationMonitor.shared.activeXcode
+ ?? ActiveApplicationMonitor.shared.latestXcode else { return }
+ let application = AXUIElementCreateApplication(xcode.processIdentifier)
+ guard let focusElement = application.focusedElement,
+ focusElement.description == "Source Editor"
+ else { return }
+ guard let (
+ content,
+ lines,
+ _,
+ cursorPosition,
+ cursorOffset
+ ) = await getFileContent(sourceEditor: nil)
+ else {
+ PresentInWindowSuggestionPresenter()
+ .presentErrorMessage("Unable to get file content.")
+ return
+ }
+ let handler = WindowBaseCommandHandler()
+ do {
+ guard let result = try await handler.acceptSuggestion(editor: .init(
+ content: content,
+ lines: lines,
+ uti: "",
+ cursorPosition: cursorPosition,
+ cursorOffset: cursorOffset,
+ selections: [],
+ tabSize: 0,
+ indentSize: 0,
+ usesTabsForIndentation: false
+ )) else { return }
+
+ try injectUpdatedCodeWithAccessibilityAPI(result, focusElement: focusElement)
+ } catch {
+ PresentInWindowSuggestionPresenter().presentError(error)
+ }
+ }
+ }
+
func acceptSuggestion() async {
do {
if UserDefaults.shared.value(for: \.alwaysAcceptSuggestionWithAccessibilityAPI) {
throw CancellationError()
}
do {
- try await XcodeInspector.shared.safe.latestActiveXcode?
+ try await XcodeInspector.shared.latestActiveXcode?
.triggerCopilotCommand(name: "Accept Suggestion")
} catch {
let last = Self.lastTimeCommandFailedToTriggerWithAccessibilityAPI
@@ -284,7 +361,7 @@ struct PseudoCommandHandler: CommandHandler {
The app is using a fallback solution to accept suggestions. \
For better experience, please restart Xcode to re-activate the Copilot \
menu item.
- """, type: .warning)
+ """, type: .warning, duration: 10)
}
throw error
@@ -330,7 +407,7 @@ struct PseudoCommandHandler: CommandHandler {
}
func dismissSuggestion() async {
- guard let documentURL = await XcodeInspector.shared.safe.activeDocumentURL else { return }
+ guard let documentURL = await XcodeInspector.shared.activeDocumentURL else { return }
PresentInWindowSuggestionPresenter().discardSuggestion(fileURL: documentURL)
guard let (_, filespace) = try? await Service.shared.workspacePool
.fetchOrCreateWorkspaceAndFilespace(fileURL: documentURL) else { return }
@@ -340,6 +417,24 @@ struct PseudoCommandHandler: CommandHandler {
func openChat(forceDetach: Bool, activateThisApp: Bool = true) {
switch UserDefaults.shared.value(for: \.openChatMode) {
case .chatPanel:
+ for ext in BuiltinExtensionManager.shared.extensions {
+ guard let tab = ext.chatTabTypes.first(where: { $0.isDefaultChatTabReplacement })
+ else { continue }
+ Task { @MainActor in
+ let store = Service.shared.guiController.store
+ await store.send(
+ .createAndSwitchToChatTabIfNeededMatching(
+ check: { $0.name == tab.name },
+ kind: .init(tab.defaultChatBuilder())
+ )
+ ).finish()
+ store.send(.openChatPanel(
+ forceDetach: forceDetach,
+ activateThisApp: activateThisApp
+ ))
+ }
+ return
+ }
Task { @MainActor in
let store = Service.shared.guiController.store
await store.send(.createAndSwitchToChatGPTChatTabIfNeeded).finish()
@@ -391,17 +486,39 @@ struct PseudoCommandHandler: CommandHandler {
#endif
} else {
Task {
- @Dependency(\.openURL) var openURL
- await openURL(url)
+ NSWorkspace.shared.open(url)
}
}
- case .codeiumChat:
+ case let .builtinExtension(extensionIdentifier, id, _):
+ guard let ext = BuiltinExtensionManager.shared.extensions
+ .first(where: { $0.extensionIdentifier == extensionIdentifier }),
+ let tab = ext.chatTabTypes.first(where: { $0.name == id })
+ else { return }
Task { @MainActor in
let store = Service.shared.guiController.store
await store.send(
.createAndSwitchToChatTabIfNeededMatching(
- check: { $0 is CodeiumChatTab },
- kind: .init(CodeiumChatTab.defaultChatBuilder())
+ check: { $0.name == id },
+ kind: .init(tab.defaultChatBuilder())
+ )
+ ).finish()
+ store.send(.openChatPanel(
+ forceDetach: forceDetach,
+ activateThisApp: activateThisApp
+ ))
+ }
+ case let .externalExtension(extensionIdentifier, id, _):
+ guard let ext = BuiltinExtensionManager.shared.extensions
+ .first(where: { $0.extensionIdentifier == "plus" }),
+ let tab = ext.chatTabTypes
+ .first(where: { $0.name == "\(extensionIdentifier).\(id)" })
+ else { return }
+ Task { @MainActor in
+ let store = Service.shared.guiController.store
+ await store.send(
+ .createAndSwitchToChatTabIfNeededMatching(
+ check: { $0.name == "\(extensionIdentifier).\(id)" },
+ kind: .init(tab.defaultChatBuilder())
)
).finish()
store.send(.openChatPanel(
@@ -422,7 +539,9 @@ struct PseudoCommandHandler: CommandHandler {
extraSystemPrompt: nil,
prompt: message,
useExtraSystemPrompt: nil
- )
+ ),
+ ignoreExistingAttachments: false,
+ attachments: []
))).finish()
}
@@ -439,6 +558,33 @@ struct PseudoCommandHandler: CommandHandler {
store.send(.suggestionWidget(.toastPanel(.toast(.toast(message, type, nil)))))
}
}
+
+ func presentFile(at fileURL: URL, line: Int?) async {
+ let terminal = Terminal()
+ do {
+ if let line {
+ _ = try await terminal.runCommand(
+ "/bin/bash",
+ arguments: [
+ "-c",
+ "xed -l \(line) ${TARGET_FILE}",
+ ],
+ environment: ["TARGET_FILE": fileURL.path],
+ )
+ } else {
+ _ = try await terminal.runCommand(
+ "/bin/bash",
+ arguments: [
+ "-c",
+ "xed ${TARGET_FILE}",
+ ],
+ environment: ["TARGET_FILE": fileURL.path],
+ )
+ }
+ } catch {
+ print(error)
+ }
+ }
}
extension PseudoCommandHandler {
@@ -523,7 +669,7 @@ extension PseudoCommandHandler {
}
func getFileURL() async -> URL? {
- await XcodeInspector.shared.safe.realtimeActiveDocumentURL
+ XcodeInspector.shared.realtimeActiveDocumentURL
}
@WorkspaceActor
@@ -541,7 +687,7 @@ extension PseudoCommandHandler {
guard let filespace = await getFilespace(),
let sourceEditor = await {
if let sourceEditor { sourceEditor }
- else { await XcodeInspector.shared.safe.focusedEditor }
+ else { await XcodeInspector.shared.latestFocusedEditor }
}()
else { return nil }
if Task.isCancelled { return nil }
@@ -564,5 +710,34 @@ extension PseudoCommandHandler {
usesTabsForIndentation: usesTabsForIndentation
)
}
+
+ func handleAcceptSuggestionLineCommand(editor: EditorContent) async throws -> CodeSuggestion? {
+ guard let _ = XcodeInspector.shared.realtimeActiveDocumentURL
+ else { return nil }
+
+ return try await acceptSuggestionLineInGroup(
+ atIndex: 0,
+ editor: editor
+ )
+ }
+
+ func acceptSuggestionLineInGroup(
+ atIndex index: Int?,
+ editor: EditorContent
+ ) async throws -> CodeSuggestion? {
+ guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL
+ else { return nil }
+ let (workspace, _) = try await Service.shared.workspacePool
+ .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL)
+
+ guard var acceptedSuggestion = await workspace.acceptSuggestion(
+ forFileAt: fileURL,
+ editor: editor
+ ) else { return nil }
+
+ let text = acceptedSuggestion.text
+ acceptedSuggestion.text = String(text.splitByNewLine().first ?? "")
+ return acceptedSuggestion
+ }
}
diff --git a/Core/Sources/Service/SuggestionCommandHandler/SuggestionCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/SuggestionCommandHandler.swift
index 3d612e82..bc2742c9 100644
--- a/Core/Sources/Service/SuggestionCommandHandler/SuggestionCommandHandler.swift
+++ b/Core/Sources/Service/SuggestionCommandHandler/SuggestionCommandHandler.swift
@@ -11,6 +11,8 @@ protocol SuggestionCommandHandler {
@ServiceActor
func rejectSuggestion(editor: EditorContent) async throws -> UpdatedContent?
@ServiceActor
+ func acceptSuggestionLine(editor: EditorContent) async throws -> UpdatedContent?
+ @ServiceActor
func acceptSuggestion(editor: EditorContent) async throws -> UpdatedContent?
@ServiceActor
func acceptPromptToCode(editor: EditorContent) async throws -> UpdatedContent?
@@ -23,3 +25,4 @@ protocol SuggestionCommandHandler {
@ServiceActor
func customCommand(id: String, editor: EditorContent) async throws -> UpdatedContent?
}
+
diff --git a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift
index 939b9652..53b0c833 100644
--- a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift
+++ b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift
@@ -1,12 +1,13 @@
import AppKit
import ChatService
import ComposableArchitecture
+import CustomCommandTemplateProcessor
import Foundation
import GitHubCopilotService
import LanguageServerProtocol
import Logger
+import ModificationBasic
import OpenAIService
-import PromptToCodeBasic
import SuggestionBasic
import SuggestionInjector
import SuggestionWidget
@@ -41,7 +42,7 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler {
defer {
presenter.markAsProcessing(false)
}
- guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL
+ guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL
else { return }
let (workspace, filespace) = try await Service.shared.workspacePool
.fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL)
@@ -75,7 +76,7 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler {
@WorkspaceActor
private func _presentNextSuggestion(editor: EditorContent) async throws {
- guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL
+ guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL
else { return }
let (workspace, filespace) = try await Service.shared.workspacePool
.fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL)
@@ -101,7 +102,7 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler {
@WorkspaceActor
private func _presentPreviousSuggestion(editor: EditorContent) async throws {
- guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL
+ guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL
else { return }
let (workspace, filespace) = try await Service.shared.workspacePool
.fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL)
@@ -127,7 +128,7 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler {
@WorkspaceActor
private func _rejectSuggestion(editor: EditorContent) async throws {
- guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL
+ guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL
else { return }
let (workspace, _) = try await Service.shared.workspacePool
@@ -138,7 +139,7 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler {
@WorkspaceActor
func acceptSuggestion(editor: EditorContent) async throws -> UpdatedContent? {
- guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL
+ guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL
else { return nil }
let (workspace, _) = try await Service.shared.workspacePool
.fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL)
@@ -171,8 +172,34 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler {
return nil
}
+ func acceptSuggestionLine(editor: EditorContent) async throws -> UpdatedContent? {
+ if let acceptedSuggestion = try await PseudoCommandHandler()
+ .handleAcceptSuggestionLineCommand(editor: editor)
+ {
+ let injector = SuggestionInjector()
+ var lines = editor.lines
+ var cursorPosition = editor.cursorPosition
+ var extraInfo = SuggestionInjector.ExtraInfo()
+
+ injector.acceptSuggestion(
+ intoContentWithoutSuggestion: &lines,
+ cursorPosition: &cursorPosition,
+ completion: acceptedSuggestion,
+ extraInfo: &extraInfo
+ )
+
+ return .init(
+ content: String(lines.joined(separator: "")),
+ newSelection: .cursor(cursorPosition),
+ modifications: extraInfo.modifications
+ )
+ }
+
+ return nil
+ }
+
func acceptPromptToCode(editor: EditorContent) async throws -> UpdatedContent? {
- guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL
+ guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL
else { return nil }
let injector = SuggestionInjector()
@@ -259,7 +286,7 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler {
@WorkspaceActor
func prepareCache(editor: EditorContent) async throws -> UpdatedContent? {
- guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL
+ guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL
else { return nil }
let (_, filespace) = try await Service.shared.workspacePool
.fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL)
@@ -354,9 +381,9 @@ extension WindowBaseCommandHandler {
generateDescription: Bool?,
name: String?
) async throws {
- guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL
+ guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL
else { return }
- let (workspace, filespace) = try await Service.shared.workspacePool
+ let (workspace, _) = try await Service.shared.workspacePool
.fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL)
guard workspace.suggestionPlugin?.isSuggestionFeatureEnabled ?? false else {
presenter.presentErrorMessage("Prompt to code is disabled for this project")
@@ -366,39 +393,21 @@ extension WindowBaseCommandHandler {
let codeLanguage = languageIdentifierFromFileURL(fileURL)
let selections: [CursorRange] = {
- var all = [CursorRange]()
-
- // join the ranges if they overlaps in line
-
- for selection in editor.selections {
- let range = CursorRange(start: selection.start, end: selection.end)
-
- func intersect(_ lhs: CursorRange, _ rhs: CursorRange) -> Bool {
- lhs.start.line <= rhs.end.line && lhs.end.line >= rhs.start.line
- }
-
- if let last = all.last, intersect(last, range) {
- all[all.count - 1] = CursorRange(
- start: .init(
- line: min(last.start.line, range.start.line),
- character: min(last.start.character, range.start.character)
- ),
- end: .init(
- line: max(last.end.line, range.end.line),
- character: max(last.end.character, range.end.character)
- )
- )
- } else {
- all.append(range)
- }
+ if let firstSelection = editor.selections.first,
+ let lastSelection = editor.selections.last
+ {
+ let range = CursorRange(
+ start: firstSelection.start,
+ end: lastSelection.end
+ )
+ return [range]
}
-
- return all
+ return []
}()
let snippets = selections.map { selection in
guard selection.start != selection.end else {
- return PromptToCodeSnippet(
+ return ModificationSnippet(
startLineIndex: selection.start.line,
originalCode: "",
modifiedCode: "",
@@ -434,7 +443,7 @@ extension WindowBaseCommandHandler {
start: selection.start,
end: selection.end
))
- return PromptToCodeSnippet(
+ return ModificationSnippet(
startLineIndex: selection.start.line,
originalCode: selectedCode,
modifiedCode: selectedCode,
@@ -471,12 +480,10 @@ extension WindowBaseCommandHandler {
lines: editor.lines
),
snippets: IdentifiedArray(uniqueElements: snippets),
- instruction: newPrompt ?? "",
extraSystemPrompt: newExtraSystemPrompt ?? "",
isAttachedToTarget: true
)),
- indentSize: filespace.codeMetadata.indentSize ?? 4,
- usesTabsForIndentation: filespace.codeMetadata.usesTabsForIndentation ?? false,
+ instruction: newPrompt,
commandName: name,
isContinuous: isContinuous
))))
diff --git a/Core/Sources/Service/XPCService.swift b/Core/Sources/Service/XPCService.swift
index 9c358ada..b8c70126 100644
--- a/Core/Sources/Service/XPCService.swift
+++ b/Core/Sources/Service/XPCService.swift
@@ -210,11 +210,13 @@ public class XPCService: NSObject, XPCServiceProtocol {
requestBody: Data,
reply: @escaping (Data?, Error?) -> Void
) {
- Service.shared.handleXPCServiceRequests(
- endpoint: endpoint,
- requestBody: requestBody,
- reply: reply
- )
+ Task {
+ await Service.shared.handleXPCServiceRequests(
+ endpoint: endpoint,
+ requestBody: requestBody,
+ reply: reply
+ )
+ }
}
}
diff --git a/Core/Sources/SuggestionWidget/ChatPanelWindow.swift b/Core/Sources/SuggestionWidget/ChatPanelWindow.swift
index 65c99c59..022b424c 100644
--- a/Core/Sources/SuggestionWidget/ChatPanelWindow.swift
+++ b/Core/Sources/SuggestionWidget/ChatPanelWindow.swift
@@ -12,9 +12,9 @@ final class ChatPanelWindow: WidgetWindow {
private let store: StoreOf
var minimizeWindow: () -> Void = {}
-
+
var isDetached: Bool {
- store.withState({$0.isDetached})
+ store.withState { $0.isDetached }
}
override var defaultCollectionBehavior: NSWindow.CollectionBehavior {
@@ -35,7 +35,7 @@ final class ChatPanelWindow: WidgetWindow {
self.minimizeWindow = minimizeWindow
super.init(
contentRect: .init(x: 0, y: 0, width: 300, height: 400),
- styleMask: [.resizable, .titled, .miniaturizable, .fullSizeContentView],
+ styleMask: [.resizable, .titled, .miniaturizable, .fullSizeContentView, .closable],
backing: .buffered,
defer: false
)
@@ -51,10 +51,7 @@ final class ChatPanelWindow: WidgetWindow {
}())
titlebarAppearsTransparent = true
isReleasedWhenClosed = false
- isOpaque = false
- backgroundColor = .clear
level = widgetLevel(1)
-
hasShadow = true
contentView = NSHostingView(
rootView: ChatWindowView(
@@ -90,16 +87,6 @@ final class ChatPanelWindow: WidgetWindow {
center()
}
- func setFloatOnTop(_ isFloatOnTop: Bool) {
- let targetLevel: NSWindow.Level = isFloatOnTop
- ? .init(NSWindow.Level.floating.rawValue + 1)
- : .normal
-
- if targetLevel != level {
- level = targetLevel
- }
- }
-
var isWindowHidden: Bool = false {
didSet {
alphaValue = isPanelDisplayed && !isWindowHidden ? 1 : 0
@@ -121,5 +108,9 @@ final class ChatPanelWindow: WidgetWindow {
override func miniaturize(_: Any?) {
minimizeWindow()
}
+
+ override func close() {
+ store.send(.closeActiveTabClicked)
+ }
}
diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift
index ee655d0b..58c6f4d7 100644
--- a/Core/Sources/SuggestionWidget/ChatWindowView.swift
+++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift
@@ -4,6 +4,7 @@ import ChatGPTChatTab
import ChatTab
import ComposableArchitecture
import SwiftUI
+import SharedUIComponents
private let r: Double = 8
@@ -21,13 +22,14 @@ struct ChatWindowView: View {
ChatTabBar(store: store)
.frame(height: 26)
+ .clipped()
Divider()
ChatTabContainer(store: store)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
- .xcodeStyleFrame(cornerRadius: 10)
+ .xcodeStyleFrame()
.ignoresSafeArea(edges: .top)
.onChange(of: store.isPanelDisplayed) { isDisplayed in
toggleVisibility(isDisplayed)
@@ -125,7 +127,7 @@ struct ChatTitleBar: View {
}
}
-private extension View {
+extension View {
func hideScrollIndicator() -> some View {
if #available(macOS 13.0, *) {
return scrollIndicators(.hidden)
@@ -200,7 +202,7 @@ struct ChatTabBar: View {
draggingTabId: $draggingTabId
)
)
-
+
} else {
ChatTabBarButton(
store: store,
diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanel.swift b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanel.swift
index ebc190d3..28bf5bfc 100644
--- a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanel.swift
+++ b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanel.swift
@@ -77,9 +77,10 @@ public struct ChatPanel {
case moveChatTab(from: Int, to: Int)
case focusActiveChatTab
- case chatTab(id: String, action: ChatTabItem.Action)
+ case chatTab(IdentifiedActionOf)
}
+ @Dependency(\.chatTabPool) var chatTabPool
@Dependency(\.suggestionWidgetControllerDependency) var suggestionWidgetControllerDependency
@Dependency(\.xcodeInspector) var xcodeInspector
@Dependency(\.activatePreviousActiveXcode) var activatePreviouslyActiveXcode
@@ -197,6 +198,7 @@ public struct ChatPanel {
return max(nextIndex, 0)
}()
state.chatTabGroup.tabInfo.removeAll { $0.id == id }
+ chatTabPool.getTab(of: id)?.close()
if state.chatTabGroup.tabInfo.isEmpty {
state.isPanelDisplayed = false
}
@@ -278,10 +280,10 @@ public struct ChatPanel {
let id = state.chatTabGroup.selectedTabInfo?.id
guard let id else { return .none }
return .run { send in
- await send(.chatTab(id: id, action: .focus))
+ await send(.chatTab(.element(id: id, action: .focus)))
}
- case let .chatTab(id, .close):
+ case let .chatTab(.element(id, .close)):
return .run { send in
await send(.closeTabButtonClicked(id: id))
}
@@ -289,7 +291,7 @@ public struct ChatPanel {
case .chatTab:
return .none
}
- }.forEach(\.chatTabGroup.tabInfo, action: /Action.chatTab) {
+ }.forEach(\.chatTabGroup.tabInfo, action: \.chatTab) {
ChatTabItem()
}
}
diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/CircularWidget.swift b/Core/Sources/SuggestionWidget/FeatureReducers/CircularWidget.swift
index 0a9e11c2..8b173c30 100644
--- a/Core/Sources/SuggestionWidget/FeatureReducers/CircularWidget.swift
+++ b/Core/Sources/SuggestionWidget/FeatureReducers/CircularWidget.swift
@@ -24,6 +24,7 @@ public struct CircularWidget {
case widgetClicked
case detachChatPanelToggleClicked
case openChatButtonClicked
+ case openModificationButtonClicked
case runCustomCommandButtonClicked(CustomCommand)
case markIsProcessing
case endIsProcessing
@@ -45,6 +46,11 @@ public struct CircularWidget {
suggestionWidgetControllerDependency.onOpenChatClicked()
}
+ case .openModificationButtonClicked:
+ return .run { _ in
+ suggestionWidgetControllerDependency.onOpenModificationButtonClicked()
+ }
+
case let .runCustomCommandButtonClicked(command):
return .run { _ in
suggestionWidgetControllerDependency.onCustomCommandClicked(command)
diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift
index 114389e3..d844b336 100644
--- a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift
+++ b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift
@@ -11,18 +11,15 @@ public struct PromptToCodeGroup {
public var promptToCodes: IdentifiedArrayOf = []
public var activeDocumentURL: PromptToCodePanel.State.ID? = XcodeInspector.shared
.realtimeActiveDocumentURL
+ public var selectedTabId: URL?
public var activePromptToCode: PromptToCodePanel.State? {
get {
- if let detached = promptToCodes
- .first(where: { !$0.promptToCodeState.isAttachedToTarget })
- {
- return detached
- }
- guard let id = activeDocumentURL else { return nil }
- return promptToCodes[id: id]
+ guard let selectedTabId else { return promptToCodes.first }
+ return promptToCodes[id: selectedTabId] ?? promptToCodes.first
}
set {
- if let id = newValue?.id {
+ selectedTabId = newValue?.id
+ if let id = selectedTabId {
promptToCodes[id: id] = newValue
}
}
@@ -32,7 +29,7 @@ public struct PromptToCodeGroup {
public enum Action {
/// Activate the prompt to code if it exists or create it if it doesn't
case activateOrCreatePromptToCode(PromptToCodePanel.State)
- case createPromptToCode(PromptToCodePanel.State)
+ case createPromptToCode(PromptToCodePanel.State, sendImmediately: Bool)
case updatePromptToCodeRange(
id: PromptToCodePanel.State.ID,
snippetId: UUID,
@@ -41,31 +38,45 @@ public struct PromptToCodeGroup {
case discardAcceptedPromptToCodeIfNotContinuous(id: PromptToCodePanel.State.ID)
case updateActivePromptToCode(documentURL: URL)
case discardExpiredPromptToCode(documentURLs: [URL])
- case promptToCode(PromptToCodePanel.State.ID, PromptToCodePanel.Action)
+ case tabClicked(id: URL)
+ case closeTabButtonClicked(id: URL)
+ case switchToNextTab
+ case switchToPreviousTab
+ case promptToCode(IdentifiedActionOf)
case activePromptToCode(PromptToCodePanel.Action)
}
- @Dependency(\.promptToCodeServiceFactory) var promptToCodeServiceFactory
@Dependency(\.activatePreviousActiveXcode) var activatePreviousActiveXcode
public var body: some ReducerOf {
Reduce { state, action in
switch action {
case let .activateOrCreatePromptToCode(s):
- if let promptToCode = state.activePromptToCode {
+ if let promptToCode = state.activePromptToCode, s.id == promptToCode.id {
+ state.selectedTabId = promptToCode.id
return .run { send in
- await send(.promptToCode(promptToCode.id, .focusOnTextField))
+ await send(.promptToCode(.element(
+ id: promptToCode.id,
+ action: .focusOnTextField
+ )))
}
}
return .run { send in
- await send(.createPromptToCode(s))
+ await send(.createPromptToCode(s, sendImmediately: false))
}
- case let .createPromptToCode(newPromptToCode):
- // insert at 0 so it has high priority then the other detached prompt to codes
- state.promptToCodes.insert(newPromptToCode, at: 0)
- return .run { send in
- if !newPromptToCode.promptToCodeState.instruction.isEmpty {
- await send(.promptToCode(newPromptToCode.id, .modifyCodeButtonTapped))
+ case let .createPromptToCode(newPromptToCode, sendImmediately):
+ var newPromptToCode = newPromptToCode
+ newPromptToCode.isActiveDocument = newPromptToCode.id == state.activeDocumentURL
+ state.promptToCodes.append(newPromptToCode)
+ state.selectedTabId = newPromptToCode.id
+ return .run { [newPromptToCode] send in
+ if sendImmediately,
+ !newPromptToCode.contextInputController.instruction.string.isEmpty
+ {
+ await send(.promptToCode(.element(
+ id: newPromptToCode.id,
+ action: .modifyCodeButtonTapped
+ )))
}
}.cancellable(
id: PromptToCodePanel.CancellationKey.modifyCode(newPromptToCode.id),
@@ -80,11 +91,21 @@ public struct PromptToCodeGroup {
return .none
case let .discardAcceptedPromptToCodeIfNotContinuous(id):
- state.promptToCodes.removeAll { $0.id == id && $0.hasEnded }
+ for itemId in state.promptToCodes.ids {
+ if itemId == id, state.promptToCodes[id: itemId]?.clickedButton == .accept {
+ state.promptToCodes.remove(id: itemId)
+ } else {
+ state.promptToCodes[id: itemId]?.clickedButton = nil
+ }
+ }
return .none
case let .updateActivePromptToCode(documentURL):
state.activeDocumentURL = documentURL
+ for index in state.promptToCodes.indices {
+ state.promptToCodes[index].isActiveDocument =
+ state.promptToCodes[index].id == documentURL
+ }
return .none
case let .discardExpiredPromptToCode(documentURLs):
@@ -93,6 +114,37 @@ public struct PromptToCodeGroup {
}
return .none
+ case let .tabClicked(id):
+ state.selectedTabId = id
+ return .none
+
+ case let .closeTabButtonClicked(id):
+ return .run { send in
+ await send(.promptToCode(.element(
+ id: id,
+ action: .cancelButtonTapped
+ )))
+ }
+
+ case .switchToNextTab:
+ if let selectedTabId = state.selectedTabId,
+ let index = state.promptToCodes.index(id: selectedTabId)
+ {
+ let nextIndex = (index + 1) % state.promptToCodes.count
+ state.selectedTabId = state.promptToCodes[nextIndex].id
+ }
+ return .none
+
+ case .switchToPreviousTab:
+ if let selectedTabId = state.selectedTabId,
+ let index = state.promptToCodes.index(id: selectedTabId)
+ {
+ let previousIndex = (index - 1 + state.promptToCodes.count) % state
+ .promptToCodes.count
+ state.selectedTabId = state.promptToCodes[previousIndex].id
+ }
+ return .none
+
case .promptToCode:
return .none
@@ -102,25 +154,29 @@ public struct PromptToCodeGroup {
}
.ifLet(\.activePromptToCode, action: \.activePromptToCode) {
PromptToCodePanel()
- .dependency(\.promptToCodeService, promptToCodeServiceFactory())
}
- .forEach(\.promptToCodes, action: /Action.promptToCode, element: {
+ .forEach(\.promptToCodes, action: \.promptToCode, element: {
PromptToCodePanel()
- .dependency(\.promptToCodeService, promptToCodeServiceFactory())
})
Reduce { state, action in
switch action {
- case let .promptToCode(id, .cancelButtonTapped):
+ case let .promptToCode(.element(id, .cancelButtonTapped)):
state.promptToCodes.remove(id: id)
+ let isEmpty = state.promptToCodes.isEmpty
return .run { _ in
- activatePreviousActiveXcode()
+ if isEmpty {
+ activatePreviousActiveXcode()
+ }
}
case .activePromptToCode(.cancelButtonTapped):
- guard let id = state.activePromptToCode?.id else { return .none }
+ guard let id = state.selectedTabId else { return .none }
state.promptToCodes.remove(id: id)
+ let isEmpty = state.promptToCodes.isEmpty
return .run { _ in
- activatePreviousActiveXcode()
+ if isEmpty {
+ activatePreviousActiveXcode()
+ }
}
default: return .none
}
diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift
index 6f2d67eb..cb68435f 100644
--- a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift
+++ b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift
@@ -1,13 +1,15 @@
import AppKit
+import ChatBasic
import ComposableArchitecture
import CustomAsyncAlgorithms
import Dependencies
import Foundation
+import ModificationBasic
import Preferences
-import PromptToCodeBasic
import PromptToCodeCustomization
import PromptToCodeService
import SuggestionBasic
+import XcodeInspector
@Reducer
public struct PromptToCodePanel {
@@ -17,12 +19,17 @@ public struct PromptToCodePanel {
case textField
}
- @Shared public var promptToCodeState: PromptToCodeState
+ public enum ClickedButton: Equatable {
+ case accept
+ case acceptAndContinue
+ }
+
+ @Shared public var promptToCodeState: ModificationState
+ @ObservationStateIgnored
+ public var contextInputController: PromptToCodeContextInputController
public var id: URL { promptToCodeState.source.documentURL }
- public var indentSize: Int
- public var usesTabsForIndentation: Bool
public var commandName: String?
public var isContinuous: Bool
public var focusedField: FocusField? = .textField
@@ -34,28 +41,29 @@ public struct PromptToCodePanel {
public var canRevert: Bool { !promptToCodeState.history.isEmpty }
public var generateDescriptionRequirement: Bool
-
- public var hasEnded = false
+
+ public var clickedButton: ClickedButton?
+
+ public var isActiveDocument: Bool = false
public var snippetPanels: IdentifiedArrayOf {
get {
IdentifiedArrayOf(
- uniqueElements: promptToCodeState.snippets.reversed().map {
+ uniqueElements: promptToCodeState.snippets.map {
PromptToCodeSnippetPanel.State(snippet: $0)
}
)
}
set {
promptToCodeState.snippets = IdentifiedArrayOf(
- uniqueElements: newValue.map(\.snippet).reversed()
+ uniqueElements: newValue.map(\.snippet)
)
}
}
public init(
- promptToCodeState: Shared,
- indentSize: Int,
- usesTabsForIndentation: Bool,
+ promptToCodeState: Shared,
+ instruction: String?,
commandName: String? = nil,
isContinuous: Bool = false,
generateDescriptionRequirement: Bool = UserDefaults.shared
@@ -63,11 +71,13 @@ public struct PromptToCodePanel {
) {
_promptToCodeState = promptToCodeState
self.isContinuous = isContinuous
- self.indentSize = indentSize
- self.usesTabsForIndentation = usesTabsForIndentation
self.generateDescriptionRequirement = generateDescriptionRequirement
self.commandName = commandName
+ contextInputController = PromptToCodeCustomization
+ .contextInputControllerFactory(promptToCodeState)
focusedField = .textField
+ contextInputController.instruction = instruction
+ .map(NSAttributedString.init(string:)) ?? .init()
}
}
@@ -83,12 +93,13 @@ public struct PromptToCodePanel {
case cancelButtonTapped
case acceptButtonTapped
case acceptAndContinueButtonTapped
- case appendNewLineToPromptButtonTapped
+ case revealFileButtonClicked
+ case statusUpdated([String])
+ case referencesUpdated([ChatMessage.Reference])
case snippetPanel(IdentifiedActionOf)
}
@Dependency(\.commandHandler) var commandHandler
- @Dependency(\.promptToCodeService) var promptToCodeService
@Dependency(\.activateThisApp) var activateThisApp
@Dependency(\.activatePreviousActiveXcode) var activatePreviousActiveXcode
@@ -118,18 +129,34 @@ public struct PromptToCodePanel {
case .modifyCodeButtonTapped:
guard !state.promptToCodeState.isGenerating else { return .none }
let copiedState = state
+ let contextInputController = state.contextInputController
state.promptToCodeState.isGenerating = true
- state.promptToCodeState.pushHistory()
+ state.promptToCodeState.pushHistory(instruction: .init(
+ attributedString: contextInputController.instruction
+ ))
+ state.promptToCodeState.references = []
let snippets = state.promptToCodeState.snippets
return .run { send in
do {
+ let context = await contextInputController.resolveContext(
+ forDocumentURL: copiedState.promptToCodeState.source.documentURL,
+ onStatusChange: { await send(.statusUpdated($0)) }
+ )
+ await send(.referencesUpdated(context.references))
+ let agentFactory = context.agent ?? { SimpleModificationAgent() }
_ = try await withThrowingTaskGroup(of: Void.self) { group in
- for snippet in snippets {
+ for (index, snippet) in snippets.enumerated() {
+ if index > 3 { // at most 3 at a time
+ _ = try await group.next()
+ }
group.addTask {
- let stream = try await promptToCodeService.modifyCode(
+ try await Task
+ .sleep(nanoseconds: UInt64.random(in: 0...1_000_000_000))
+ let agent = agentFactory()
+ let stream = agent.send(.init(
code: snippet.originalCode,
- requirement: copiedState.promptToCodeState.instruction,
+ requirement: context.instruction,
source: .init(
language: copiedState.promptToCodeState.source.language,
documentURL: copiedState.promptToCodeState.source
@@ -137,44 +164,62 @@ public struct PromptToCodePanel {
projectRootURL: copiedState.promptToCodeState.source
.projectRootURL,
content: copiedState.promptToCodeState.source.content,
- lines: copiedState.promptToCodeState.source.lines,
- range: snippet.attachedRange
+ lines: copiedState.promptToCodeState.source.lines
),
isDetached: !copiedState.promptToCodeState
.isAttachedToTarget,
extraSystemPrompt: copiedState.promptToCodeState
.extraSystemPrompt,
- generateDescriptionRequirement: copiedState
- .generateDescriptionRequirement
- ).timedDebounce(for: 0.2)
+ range: snippet.attachedRange,
+ references: context.references,
+ topics: context.topics
+ )).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 fragment in stream {
+ for try await response in stream {
try Task.checkCancellation()
await send(.snippetPanel(.element(
id: snippet.id,
action: .modifyCodeChunkReceived(
- code: fragment.code,
- description: fragment.description
+ 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,
action: .modifyCodeFailed(
- error: error
- .localizedDescription
+ error: error.localizedDescription
)
)))
}
@@ -194,20 +239,24 @@ public struct PromptToCodePanel {
}.cancellable(id: CancellationKey.modifyCode(state.id), cancelInFlight: true)
case .revertButtonTapped:
- state.promptToCodeState.popHistory()
+ if let instruction = state.promptToCodeState.popHistory() {
+ state.contextInputController.instruction = instruction
+ }
return .none
case .stopRespondingButtonTapped:
state.promptToCodeState.isGenerating = false
- promptToCodeService.stopResponding()
+ state.promptToCodeState.status = []
return .cancel(id: CancellationKey.modifyCode(state.id))
case .modifyCodeFinished:
- state.promptToCodeState.instruction = ""
+ state.contextInputController.instruction = .init("")
state.promptToCodeState.isGenerating = false
+ state.promptToCodeState.status = []
if state.promptToCodeState.snippets.allSatisfy({ snippet in
- snippet.modifiedCode.isEmpty && snippet.description.isEmpty
+ snippet.modifiedCode.isEmpty && snippet.description.isEmpty && snippet
+ .error == nil
}) {
// if both code and description are empty, we treat it as failed
return .run { send in
@@ -221,24 +270,35 @@ public struct PromptToCodePanel {
return .none
case .cancelButtonTapped:
- promptToCodeService.stopResponding()
return .cancel(id: CancellationKey.modifyCode(state.id))
case .acceptButtonTapped:
- state.hasEnded = true
+ state.clickedButton = .accept
return .run { _ in
- await commandHandler.acceptPromptToCode()
+ await commandHandler.acceptModification()
activatePreviousActiveXcode()
}
-
+
case .acceptAndContinueButtonTapped:
+ state.clickedButton = .acceptAndContinue
return .run { _ in
- await commandHandler.acceptPromptToCode()
+ await commandHandler.acceptModification()
activateThisApp()
}
- case .appendNewLineToPromptButtonTapped:
- state.promptToCodeState.instruction += "\n"
+ case .revealFileButtonClicked:
+ let url = state.promptToCodeState.source.documentURL
+ let startLine = state.snippetPanels.first?.snippet.attachedRange.start.line ?? 0
+ return .run { _ in
+ await commandHandler.presentFile(at: url, line: startLine)
+ }
+
+ case let .statusUpdated(status):
+ state.promptToCodeState.status = status
+ return .none
+
+ case let .referencesUpdated(references):
+ state.promptToCodeState.references = references
return .none
}
}
@@ -254,7 +314,7 @@ public struct PromptToCodeSnippetPanel {
@ObservableState
public struct State: Identifiable {
public var id: UUID { snippet.id }
- var snippet: PromptToCodeSnippet
+ var snippet: ModificationSnippet
}
public enum Action {
@@ -271,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):
@@ -288,3 +348,17 @@ public struct PromptToCodeSnippetPanel {
}
}
+final class DefaultPromptToCodeContextInputControllerDelegate: PromptToCodeContextInputControllerDelegate {
+ let store: StoreOf
+
+ init(store: StoreOf) {
+ self.store = store
+ }
+
+ func modifyCodeButtonClicked() {
+ Task {
+ await store.send(.modifyCodeButtonTapped)
+ }
+ }
+}
+
diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanel.swift b/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanel.swift
index b255949c..9f38210e 100644
--- a/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanel.swift
+++ b/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanel.swift
@@ -7,7 +7,6 @@ public struct SharedPanel {
public struct Content {
public var promptToCodeGroup = PromptToCodeGroup.State()
var suggestion: PresentingCodeSuggestion?
- public var promptToCode: PromptToCodePanel.State? { promptToCodeGroup.activePromptToCode }
var error: String?
}
@@ -19,7 +18,7 @@ public struct SharedPanel {
var isPanelDisplayed: Bool = false
var isEmpty: Bool {
if content.error != nil { return false }
- if content.promptToCode != nil { return false }
+ if !content.promptToCodeGroup.promptToCodes.isEmpty { return false }
if content.suggestion != nil,
UserDefaults.shared
.value(for: \.suggestionPresentationMode) == .floatingWidget { return false }
diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/Widget.swift b/Core/Sources/SuggestionWidget/FeatureReducers/Widget.swift
index b8c20fa7..493628fc 100644
--- a/Core/Sources/SuggestionWidget/FeatureReducers/Widget.swift
+++ b/Core/Sources/SuggestionWidget/FeatureReducers/Widget.swift
@@ -232,12 +232,18 @@ public struct Widget {
case .observeActiveApplicationChange:
return .run { send in
let stream = AsyncStream { continuation in
- let cancellable = xcodeInspector.$activeApplication.sink { newValue in
- guard let newValue else { return }
- continuation.yield(newValue)
+ let task = Task {
+ let notifications = NotificationCenter.default
+ .notifications(named: .activeApplicationDidChange)
+ for await _ in notifications {
+ try Task.checkCancellation()
+ if let app = await XcodeInspector.shared.activeApplication {
+ continuation.yield(app)
+ }
+ }
}
continuation.onTermination = { _ in
- cancellable.cancel()
+ task.cancel()
}
}
@@ -305,8 +311,7 @@ public struct Widget {
case .updateFocusingDocumentURL:
return .run { send in
await send(.setFocusingDocumentURL(
- to: await xcodeInspector.safe
- .realtimeActiveDocumentURL
+ to: xcodeInspector.realtimeActiveDocumentURL
))
}
diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetPanel.swift b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetPanel.swift
index 10d409cf..7d911f75 100644
--- a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetPanel.swift
+++ b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetPanel.swift
@@ -55,7 +55,7 @@ public struct WidgetPanel {
switch action {
case .presentSuggestion:
return .run { send in
- guard let fileURL = await xcodeInspector.safe.activeDocumentURL,
+ guard let fileURL = await xcodeInspector.activeDocumentURL,
let provider = await fetchSuggestionProvider(fileURL: fileURL)
else { return }
await send(.presentSuggestionProvider(provider, displayContent: true))
@@ -78,7 +78,10 @@ public struct WidgetPanel {
case let .presentPromptToCode(initialState):
return .run { send in
- await send(.sharedPanel(.promptToCodeGroup(.createPromptToCode(initialState))))
+ await send(.sharedPanel(.promptToCodeGroup(.createPromptToCode(
+ initialState,
+ sendImmediately: true
+ ))))
}
case .displayPanelContent:
@@ -98,7 +101,7 @@ public struct WidgetPanel {
case .switchToAnotherEditorAndUpdateContent:
return .run { send in
- guard let fileURL = await xcodeInspector.safe.realtimeActiveDocumentURL
+ guard let fileURL = xcodeInspector.realtimeActiveDocumentURL
else { return }
await send(.sharedPanel(
@@ -115,7 +118,7 @@ public struct WidgetPanel {
case .sharedPanel(.promptToCodeGroup(.activateOrCreatePromptToCode)),
.sharedPanel(.promptToCodeGroup(.createPromptToCode)):
- let hasPromptToCode = state.content.promptToCode != nil
+ let hasPromptToCode = !state.content.promptToCodeGroup.promptToCodes.isEmpty
return .run { send in
await send(.displayPanelContent)
diff --git a/Core/Sources/SuggestionWidget/ModuleDependency.swift b/Core/Sources/SuggestionWidget/ModuleDependency.swift
index 0e83df6a..5ca16f76 100644
--- a/Core/Sources/SuggestionWidget/ModuleDependency.swift
+++ b/Core/Sources/SuggestionWidget/ModuleDependency.swift
@@ -12,6 +12,7 @@ import XcodeInspector
public final class SuggestionWidgetControllerDependency {
public var suggestionWidgetDataSource: SuggestionWidgetDataSource?
public var onOpenChatClicked: () -> Void = {}
+ public var onOpenModificationButtonClicked: () -> Void = {}
public var onCustomCommandClicked: (CustomCommand) -> Void = { _ in }
var windowsController: WidgetWindowsController?
diff --git a/Core/Sources/SuggestionWidget/PromptToCodePanelGroupView.swift b/Core/Sources/SuggestionWidget/PromptToCodePanelGroupView.swift
new file mode 100644
index 00000000..739fe6b7
--- /dev/null
+++ b/Core/Sources/SuggestionWidget/PromptToCodePanelGroupView.swift
@@ -0,0 +1,141 @@
+import ComposableArchitecture
+import Foundation
+import SwiftUI
+
+struct PromptToCodePanelGroupView: View {
+ let store: StoreOf
+
+ var body: some View {
+ WithPerceptionTracking {
+ VStack(spacing: 0) {
+ PromptToCodeTabBar(store: store)
+ .frame(height: 26)
+
+ Divider()
+
+ if let store = self.store.scope(
+ state: \.activePromptToCode,
+ action: \.activePromptToCode
+ ) {
+ PromptToCodePanelView(store: store)
+ }
+ }
+ .background(.ultraThickMaterial)
+ .xcodeStyleFrame()
+ }
+ }
+}
+
+struct PromptToCodeTabBar: View {
+ let store: StoreOf
+
+ struct TabInfo: Equatable, Identifiable {
+ var id: URL
+ var tabTitle: String
+ var isProcessing: Bool
+ }
+
+ var body: some View {
+ HStack(spacing: 0) {
+ Tabs(store: store)
+ }
+ .background {
+ Button(action: { store.send(.switchToNextTab) }) { EmptyView() }
+ .opacity(0)
+ .keyboardShortcut("]", modifiers: [.command, .shift])
+ Button(action: { store.send(.switchToPreviousTab) }) { EmptyView() }
+ .opacity(0)
+ .keyboardShortcut("[", modifiers: [.command, .shift])
+ }
+ }
+
+ struct Tabs: View {
+ let store: StoreOf
+
+ var body: some View {
+ WithPerceptionTracking {
+ let tabInfo = store.promptToCodes.map {
+ TabInfo(
+ id: $0.id,
+ tabTitle: $0.filename,
+ isProcessing: $0.promptToCodeState.isGenerating
+ )
+ }
+ let selectedTabId = store.selectedTabId
+ ?? store.promptToCodes.first?.id
+
+ ScrollViewReader { proxy in
+ ScrollView(.horizontal) {
+ HStack(spacing: 0) {
+ ForEach(tabInfo) { info in
+ WithPerceptionTracking {
+ PromptToCodeTabBarButton(
+ store: store,
+ info: info,
+ isSelected: info.id == store.selectedTabId
+ )
+ .id(info.id)
+ }
+ }
+ }
+ }
+ .hideScrollIndicator()
+ .onChange(of: selectedTabId) { id in
+ withAnimation(.easeInOut(duration: 0.2)) {
+ proxy.scrollTo(id)
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+struct PromptToCodeTabBarButton: View {
+ let store: StoreOf
+ let info: PromptToCodeTabBar.TabInfo
+ let isSelected: Bool
+ @State var isHovered: Bool = false
+
+ var body: some View {
+ HStack(spacing: 0) {
+ HStack(spacing: 4) {
+ if info.isProcessing {
+ ProgressView()
+ .controlSize(.small)
+ }
+ Text(info.tabTitle)
+ .truncationMode(.middle)
+ .allowsTightening(true)
+ }
+ .font(.callout)
+ .lineLimit(1)
+ .frame(maxWidth: 120)
+ .padding(.horizontal, 28)
+ .contentShape(Rectangle())
+ .onTapGesture {
+ store.send(.tabClicked(id: info.id))
+ }
+ .overlay(alignment: .leading) {
+ Button(action: {
+ store.send(.closeTabButtonClicked(id: info.id))
+ }) {
+ Image(systemName: "xmark")
+ .foregroundColor(.secondary)
+ }
+ .buttonStyle(.plain)
+ .padding(2)
+ .padding(.leading, 8)
+ .opacity(isHovered ? 1 : 0)
+ }
+ .onHover { isHovered = $0 }
+ .animation(.linear(duration: 0.1), value: isHovered)
+ .animation(.linear(duration: 0.1), value: isSelected)
+
+ Divider().padding(.vertical, 6)
+ }
+ .background(isSelected ? Color(nsColor: .selectedControlColor) : Color.clear)
+ .frame(maxHeight: .infinity)
+ }
+}
+
diff --git a/Core/Sources/SuggestionWidget/SharedPanelView.swift b/Core/Sources/SuggestionWidget/SharedPanelView.swift
index 1c1c1a91..a00b2fee 100644
--- a/Core/Sources/SuggestionWidget/SharedPanelView.swift
+++ b/Core/Sources/SuggestionWidget/SharedPanelView.swift
@@ -72,7 +72,7 @@ struct SharedPanelView: View {
ZStack(alignment: .topLeading) {
if let errorMessage = store.content.error {
error(errorMessage)
- } else if let _ = store.content.promptToCode {
+ } else if !store.content.promptToCodeGroup.promptToCodes.isEmpty {
promptToCode()
} else if let suggestionProvider = store.content.suggestion {
suggestion(suggestionProvider)
@@ -93,12 +93,10 @@ struct SharedPanelView: View {
@ViewBuilder
func promptToCode() -> some View {
- if let store = store.scope(
- state: \.content.promptToCodeGroup.activePromptToCode,
- action: \.promptToCodeGroup.activePromptToCode
- ) {
- PromptToCodePanelView(store: store)
- }
+ PromptToCodePanelGroupView(store: store.scope(
+ state: \.content.promptToCodeGroup,
+ action: \.promptToCodeGroup
+ ))
}
@ViewBuilder
diff --git a/Core/Sources/SuggestionWidget/Styles.swift b/Core/Sources/SuggestionWidget/Styles.swift
index 011dcaf1..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
}
@@ -45,35 +46,6 @@ extension NSAppearance {
}
}
-struct XcodeLikeFrame: View {
- @Environment(\.colorScheme) var colorScheme
- let content: Content
- let cornerRadius: Double
-
- var body: some View {
- content.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
- .background(
- RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
- .fill(Material.bar)
- )
- .overlay(
- RoundedRectangle(cornerRadius: max(0, cornerRadius), style: .continuous)
- .stroke(Color.black.opacity(0.1), style: .init(lineWidth: 1))
- ) // 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))
- .padding(1)
- )
- }
-}
-
-extension View {
- func xcodeStyleFrame(cornerRadius: Double? = nil) -> some View {
- XcodeLikeFrame(content: self, cornerRadius: cornerRadius ?? 10)
- }
-}
-
extension MarkdownUI.Theme {
static func custom(fontSize: Double) -> MarkdownUI.Theme {
.gitHub.text {
diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanelView.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanelView.swift
index cef8a0ad..e876728f 100644
--- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanelView.swift
+++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanelView.swift
@@ -54,6 +54,8 @@ struct CodeBlockSuggestionPanelView: View {
struct ToolBar: View {
@Dependency(\.commandHandler) var commandHandler
+ @Environment(\.modifierFlags) var modifierFlags
+ @AppStorage(\.acceptSuggestionLineWithModifierControl) var acceptLineWithControl
let suggestion: PresentingCodeSuggestion
var body: some View {
@@ -98,14 +100,25 @@ struct CodeBlockSuggestionPanelView: View {
Text("Reject")
}.buttonStyle(CommandButtonStyle(color: .gray))
- Button(action: {
- Task {
- await commandHandler.acceptSuggestion()
- NSWorkspace.activatePreviousActiveXcode()
- }
- }) {
- Text("Accept")
- }.buttonStyle(CommandButtonStyle(color: .accentColor))
+ if modifierFlags.contains(.control) && acceptLineWithControl {
+ Button(action: {
+ Task {
+ await commandHandler.acceptActiveSuggestionLineInGroup(atIndex: nil)
+ NSWorkspace.activatePreviousActiveXcode()
+ }
+ }) {
+ Text("Accept Line")
+ }.buttonStyle(CommandButtonStyle(color: .gray))
+ } else {
+ Button(action: {
+ Task {
+ await commandHandler.acceptSuggestion()
+ NSWorkspace.activatePreviousActiveXcode()
+ }
+ }) {
+ Text("Accept")
+ }.buttonStyle(CommandButtonStyle(color: .accentColor))
+ }
}
.padding(6)
.foregroundColor(.secondary)
@@ -116,6 +129,8 @@ struct CodeBlockSuggestionPanelView: View {
struct CompactToolBar: View {
@Dependency(\.commandHandler) var commandHandler
+ @Environment(\.modifierFlags) var modifierFlags
+ @AppStorage(\.acceptSuggestionLineWithModifierControl) var acceptLineWithControl
let suggestion: PresentingCodeSuggestion
var body: some View {
@@ -139,6 +154,12 @@ struct CodeBlockSuggestionPanelView: View {
}.buttonStyle(.plain)
Spacer()
+
+ if modifierFlags.contains(.control) && acceptLineWithControl {
+ Text("Accept Line")
+ .foregroundColor(.secondary)
+ .padding(.trailing, 4)
+ }
Button(action: {
Task {
@@ -256,8 +277,13 @@ struct CodeBlockSuggestionPanelView: View {
}
.xcodeStyleFrame(cornerRadius: {
switch suggestionPresentationMode {
- case .nearbyTextCursor: 6
- case .floatingWidget: nil
+ case .nearbyTextCursor:
+ if #available(macOS 26.0, *) {
+ return 8
+ } else {
+ return 6
+ }
+ case .floatingWidget: return nil
}
}())
}
diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift
index 79809b72..ef3b560c 100644
--- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift
+++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift
@@ -1,7 +1,8 @@
+import ChatBasic
import Cocoa
import ComposableArchitecture
import MarkdownUI
-import PromptToCodeBasic
+import ModificationBasic
import PromptToCodeCustomization
import SharedUIComponents
import SuggestionBasic
@@ -9,29 +10,40 @@ import SwiftUI
struct PromptToCodePanelView: View {
let store: StoreOf
+ @FocusState var isTextFieldFocused: Bool
var body: some View {
WithPerceptionTracking {
PromptToCodeCustomization.CustomizedUI(
state: store.$promptToCodeState,
- isInputFieldFocused: .constant(true)
- ) { _ in
+ delegate: DefaultPromptToCodeContextInputControllerDelegate(store: store),
+ contextInputController: store.contextInputController,
+ isInputFieldFocused: _isTextFieldFocused
+ ) { customizedViews in
VStack(spacing: 0) {
TopBar(store: store)
Content(store: store)
- .overlay(alignment: .bottom) {
- ActionBar(store: store)
- .padding(.bottom, 8)
- }
+ .safeAreaInset(edge: .bottom) {
+ VStack {
+ StatusBar(store: store)
- Divider()
+ ActionBar(store: store)
- Toolbar(store: store)
+ if let inputField = customizedViews.contextInputField {
+ inputField
+ } else {
+ Toolbar(store: store)
+ }
+ }
+ }
}
}
- .background(.ultraThickMaterial)
- .xcodeStyleFrame()
+ }
+ .task {
+ await MainActor.run {
+ isTextFieldFocused = true
+ }
}
}
}
@@ -43,14 +55,6 @@ extension PromptToCodePanelView {
var body: some View {
WithPerceptionTracking {
VStack(spacing: 0) {
- HStack {
- SelectionRangeButton(store: store)
- Spacer()
- }
- .padding(2)
-
- Divider()
-
if let previousStep = store.promptToCodeState.history.last {
Button(action: {
store.send(.revertButtonTapped)
@@ -58,7 +62,7 @@ extension PromptToCodePanelView {
HStack(spacing: 4) {
Text(Image(systemName: "arrow.uturn.backward.circle.fill"))
.foregroundStyle(.secondary)
- Text(previousStep.instruction)
+ Text(previousStep.instruction.string)
.lineLimit(1)
.truncationMode(.tail)
.foregroundStyle(.secondary)
@@ -133,11 +137,76 @@ extension PromptToCodePanelView {
}
}
+ struct StatusBar: View {
+ let store: StoreOf
+ @State var isAllStatusesPresented = false
+ var body: some View {
+ WithPerceptionTracking {
+ if store.promptToCodeState.isGenerating, !store.promptToCodeState.status.isEmpty {
+ if let firstStatus = store.promptToCodeState.status.first {
+ let count = store.promptToCodeState.status.count
+ Button(action: {
+ isAllStatusesPresented = true
+ }) {
+ HStack {
+ Text(firstStatus)
+ .lineLimit(1)
+ .font(.caption)
+
+ Text("\(count)")
+ .lineLimit(1)
+ .font(.caption)
+ .background(
+ Circle()
+ .fill(Color.secondary.opacity(0.3))
+ .frame(width: 12, height: 12)
+ )
+ }
+ .padding(8)
+ .background(
+ .regularMaterial,
+ in: RoundedRectangle(cornerRadius: 6, style: .continuous)
+ )
+ .overlay {
+ RoundedRectangle(cornerRadius: 6, style: .continuous)
+ .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
+ }
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .frame(maxWidth: 400)
+ .popover(isPresented: $isAllStatusesPresented, arrowEdge: .top) {
+ WithPerceptionTracking {
+ VStack(alignment: .leading, spacing: 16) {
+ ForEach(store.promptToCodeState.status, id: \.self) { status in
+ HStack {
+ ProgressView()
+ .progressViewStyle(CircularProgressViewStyle())
+ .controlSize(.small)
+ Text(status)
+ }
+ }
+ }
+ .padding()
+ }
+ }
+ .onChange(of: store.promptToCodeState.isGenerating) { isGenerating in
+ if !isGenerating {
+ isAllStatusesPresented = false
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
struct ActionBar: View {
let store: StoreOf
var body: some View {
HStack {
+ ReferencesButton(store: store)
StopRespondingButton(store: store)
ActionButtons(store: store)
}
@@ -172,20 +241,55 @@ extension PromptToCodePanelView {
}
}
+ struct ReferencesButton: View {
+ let store: StoreOf
+ @State var isReferencesPresented = false
+ @State var isReferencesHovered = false
+
+ var body: some View {
+ if !store.promptToCodeState.references.isEmpty {
+ Button(action: {
+ isReferencesPresented.toggle()
+ }, label: {
+ HStack(spacing: 4) {
+ Image(systemName: "doc.text.magnifyingglass")
+ Text("\(store.promptToCodeState.references.count)")
+ }
+ .padding(8)
+ .background(
+ .regularMaterial,
+ in: RoundedRectangle(cornerRadius: 6, style: .continuous)
+ )
+ .overlay {
+ RoundedRectangle(cornerRadius: 6, style: .continuous)
+ .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
+ }
+ })
+ .buttonStyle(.plain)
+ .popover(isPresented: $isReferencesPresented, arrowEdge: .trailing) {
+ ReferenceList(store: store)
+ }
+ .onHover { hovering in
+ withAnimation {
+ isReferencesHovered = hovering
+ }
+ }
+ }
+ }
+ }
+
struct ActionButtons: View {
@Perception.Bindable var store: StoreOf
+ @AppStorage(\.chatModels) var chatModels
+ @AppStorage(\.promptToCodeChatModelId) var defaultChatModelId
var body: some View {
WithPerceptionTracking {
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 {
@@ -198,6 +302,8 @@ extension PromptToCodePanelView {
)
.toggleStyle(.checkbox)
}
+
+ chatModelMenu
} label: {
Image(systemName: "gearshape.fill")
.resizable()
@@ -217,8 +323,12 @@ extension PromptToCodePanelView {
.buttonStyle(CommandButtonStyle(color: .gray))
.keyboardShortcut("w", modifiers: [.command])
- if !isCodeEmpty {
- AcceptButton(store: store)
+ if store.isActiveDocument {
+ if !isCodeEmpty {
+ AcceptButton(store: store)
+ }
+ } else {
+ RevealButton(store: store)
}
}
.fixedSize()
@@ -238,6 +348,69 @@ extension PromptToCodePanelView {
}
}
}
+
+ @ViewBuilder
+ var chatModelMenu: some View {
+ let allModels = chatModels
+
+ Menu("Chat Model") {
+ Button(action: {
+ defaultChatModelId = ""
+ }) {
+ HStack {
+ Text("Same as chat feature")
+ if defaultChatModelId.isEmpty {
+ Image(systemName: "checkmark")
+ }
+ }
+ }
+
+ if !allModels.contains(where: { $0.id == defaultChatModelId }),
+ !defaultChatModelId.isEmpty
+ {
+ Button(action: {
+ defaultChatModelId = allModels.first?.id ?? ""
+ }) {
+ HStack {
+ Text(
+ (allModels.first?.name).map { "\($0) (Default)" }
+ ?? "No model found"
+ )
+ Image(systemName: "checkmark")
+ }
+ }
+ }
+
+ ForEach(allModels, id: \.id) { model in
+ Button(action: {
+ defaultChatModelId = model.id
+ }) {
+ HStack {
+ Text(model.name)
+ if model.id == defaultChatModelId {
+ Image(systemName: "checkmark")
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ struct RevealButton: View {
+ let store: StoreOf
+
+ var body: some View {
+ WithPerceptionTracking {
+ Button(action: {
+ store.send(.revealFileButtonClicked)
+ }) {
+ Text("Jump to File(⌘ + ⏎)")
+ }
+ .buttonStyle(CommandButtonStyle(color: .accentColor))
+ .keyboardShortcut(KeyEquivalent.return, modifiers: [.command])
+ }
+ }
}
struct AcceptButton: View {
@@ -398,8 +571,6 @@ extension PromptToCodePanelView {
ScrollView {
WithPerceptionTracking {
VStack(spacing: 0) {
- Spacer(minLength: 56)
-
VStack(spacing: 0) {
let language = store.promptToCodeState.source.language
let isAttached = store.promptToCodeState.isAttachedToTarget
@@ -410,10 +581,6 @@ extension PromptToCodePanelView {
action: \.snippetPanel
)) { snippetStore in
WithPerceptionTracking {
- if snippetStore.id != lastId {
- Divider()
- }
-
SnippetPanelView(
store: snippetStore,
language: language,
@@ -422,14 +589,19 @@ extension PromptToCodePanelView {
isAttached: isAttached,
isGenerating: isGenerating
)
+
+ if snippetStore.id != lastId {
+ Divider()
+ }
}
}
}
+
+ Spacer(minLength: 56)
}
}
}
.background(codeBackgroundColor)
- .scaleEffect(x: 1, y: -1, anchor: .center)
}
}
@@ -443,21 +615,24 @@ extension PromptToCodePanelView {
var body: some View {
WithPerceptionTracking {
- VStack(spacing: 0) {
- ErrorMessage(store: store)
+ 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
)
- SnippetTitleBar(
- store: store,
- language: language,
- codeForegroundColor: codeForegroundColor,
- isAttached: isAttached
- )
+
+ ErrorMessage(store: store)
}
}
}
@@ -484,7 +659,6 @@ extension PromptToCodePanelView {
CopyCodeButton(store: store)
}
.padding(.leading, 8)
- .scaleEffect(x: 1, y: -1, anchor: .center)
}
}
}
@@ -494,8 +668,10 @@ extension PromptToCodePanelView {
var body: some View {
WithPerceptionTracking {
if !store.snippet.modifiedCode.isEmpty {
- CopyButton {
- store.send(.copyCodeButtonTapped)
+ DraggableCopyButton {
+ store.withState {
+ $0.snippet.modifiedCode
+ }
}
}
}
@@ -517,7 +693,6 @@ extension PromptToCodePanelView {
.foregroundColor(.red)
.padding(.horizontal, 8)
.padding(.vertical, 4)
- .scaleEffect(x: 1, y: -1, anchor: .center)
}
}
}
@@ -539,7 +714,6 @@ extension PromptToCodePanelView {
.padding(.horizontal)
.padding(.vertical, 4)
.frame(maxWidth: .infinity)
- .scaleEffect(x: 1, y: -1, anchor: .center)
}
}
}
@@ -563,14 +737,16 @@ extension PromptToCodePanelView {
CodeBlockInContent(
store: store,
language: language,
- codeForegroundColor: codeForegroundColor
+ codeForegroundColor: codeForegroundColor,
+ presentAllContent: !isGenerating
)
} else {
- ScrollView(.horizontal) {
+ MinScrollView {
CodeBlockInContent(
store: store,
language: language,
- codeForegroundColor: codeForegroundColor
+ codeForegroundColor: codeForegroundColor,
+ presentAllContent: !isGenerating
)
}
.modify {
@@ -587,22 +763,52 @@ extension PromptToCodePanelView {
.foregroundStyle(.secondary)
.padding(.horizontal, 8)
.padding(.vertical, 4)
- .scaleEffect(x: 1, y: -1, anchor: .center)
} else {
Text("Enter your requirements to generate code.")
.foregroundStyle(.secondary)
.padding(.horizontal, 8)
.padding(.vertical, 4)
- .scaleEffect(x: 1, y: -1, anchor: .center)
}
}
}
}
+ struct MinWidthPreferenceKey: PreferenceKey {
+ static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
+ value = nextValue()
+ }
+
+ static var defaultValue: CGFloat = 0
+ }
+
+ struct MinScrollView: View {
+ @ViewBuilder let content: Content
+ @State var minWidth: CGFloat = 0
+
+ var body: some View {
+ ScrollView(.horizontal) {
+ content
+ .frame(minWidth: minWidth)
+ }
+ .overlay {
+ GeometryReader { proxy in
+ Color.clear.preference(
+ key: MinWidthPreferenceKey.self,
+ value: proxy.size.width
+ )
+ }
+ }
+ .onPreferenceChange(MinWidthPreferenceKey.self) {
+ minWidth = $0
+ }
+ }
+ }
+
struct CodeBlockInContent: View {
let store: StoreOf
let language: CodeLanguage
let codeForegroundColor: Color?
+ let presentAllContent: Bool
@Environment(\.colorScheme) var colorScheme
@AppStorage(\.promptToCodeCodeFont) var codeFont
@@ -611,7 +817,7 @@ extension PromptToCodePanelView {
var body: some View {
WithPerceptionTracking {
let startLineIndex = store.snippet.attachedRange.start.line
- AsyncCodeBlock(
+ AsyncDiffCodeBlock(
code: store.snippet.modifiedCode,
originalCode: store.snippet.originalCode,
language: language.rawValue,
@@ -620,11 +826,9 @@ extension PromptToCodePanelView {
font: codeFont.value.nsFont,
droppingLeadingSpaces: hideCommonPrecedingSpaces,
proposedForegroundColor: codeForegroundColor,
- ignoreWholeLineChangeInDiff: false
+ skipLastOnlyRemovalSection: !presentAllContent
)
- .frame(maxWidth: .infinity)
-
- .scaleEffect(x: 1, y: -1, anchor: .center)
+ .frame(maxWidth: CGFloat.infinity)
}
}
}
@@ -638,8 +842,16 @@ extension PromptToCodePanelView {
var body: some View {
HStack {
HStack(spacing: 0) {
- InputField(store: store, focusField: $focusField)
- SendButton(store: store)
+ if let contextInputController = store.contextInputController
+ as? DefaultPromptToCodeContextInputController
+ {
+ InputField(
+ store: store,
+ contextInputField: contextInputController,
+ focusField: $focusField
+ )
+ SendButton(store: store)
+ }
}
.frame(maxWidth: .infinity)
.background {
@@ -651,7 +863,12 @@ extension PromptToCodePanelView {
.stroke(Color(nsColor: .controlColor), lineWidth: 1)
}
.background {
- Button(action: { store.send(.appendNewLineToPromptButtonTapped) }) {
+ Button(action: {
+ (
+ store.contextInputController
+ as? DefaultPromptToCodeContextInputController
+ )?.appendNewLineToPromptButtonTapped()
+ }) {
EmptyView()
}
.keyboardShortcut(KeyEquivalent.return, modifiers: [.shift])
@@ -669,12 +886,13 @@ extension PromptToCodePanelView {
struct InputField: View {
@Perception.Bindable var store: StoreOf
+ @Perception.Bindable var contextInputField: DefaultPromptToCodeContextInputController
var focusField: FocusState.Binding
var body: some View {
WithPerceptionTracking {
AutoresizingCustomTextEditor(
- text: $store.promptToCodeState.instruction,
+ text: $contextInputField.instructionString,
font: .systemFont(ofSize: 14),
isEditable: !store.promptToCodeState.isGenerating,
maxHeight: 400,
@@ -707,13 +925,164 @@ extension PromptToCodePanelView {
}
}
}
+
+ struct ReferenceList: View {
+ @Perception.Bindable var store: StoreOf
+
+ var body: some View {
+ WithPerceptionTracking {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 8) {
+ ForEach(
+ 0.. Void
+
+ var body: some View {
+ Button(action: onClick) {
+ VStack(alignment: .leading, spacing: 4) {
+ HStack(spacing: 4) {
+ ReferenceIcon(kind: reference.kind)
+ .layoutPriority(2)
+ Text(reference.title)
+ .truncationMode(.middle)
+ .lineLimit(1)
+ .layoutPriority(1)
+ .foregroundStyle(isUsed ? .primary : .secondary)
+ }
+ Text(reference.content)
+ .lineLimit(3)
+ .truncationMode(.tail)
+ .foregroundStyle(.tertiary)
+ .foregroundStyle(isUsed ? .secondary : .tertiary)
+ }
+ .padding(.vertical, 4)
+ .padding(.leading, 4)
+ .padding(.trailing)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .overlay {
+ RoundedRectangle(cornerRadius: 4)
+ .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
+ }
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+
+ struct ReferenceIcon: View {
+ let kind: ChatMessage.Reference.Kind
+
+ var body: some View {
+ RoundedRectangle(cornerRadius: 4)
+ .fill({
+ switch kind {
+ case let .symbol(symbol, _, _, _):
+ switch symbol {
+ case .class:
+ Color.purple
+ case .struct:
+ Color.purple
+ case .enum:
+ Color.purple
+ case .actor:
+ Color.purple
+ case .protocol:
+ Color.purple
+ case .extension:
+ Color.indigo
+ case .case:
+ Color.green
+ case .property:
+ Color.teal
+ case .typealias:
+ Color.orange
+ case .function:
+ Color.teal
+ case .method:
+ Color.blue
+ }
+ case .text:
+ Color.gray
+ case .webpage:
+ Color.blue
+ case .textFile:
+ Color.gray
+ case .other:
+ Color.gray
+ case .error:
+ Color.red
+ }
+ }())
+ .frame(width: 26, height: 14)
+ .overlay(alignment: .center) {
+ Group {
+ switch kind {
+ case let .symbol(symbol, _, _, _):
+ switch symbol {
+ case .class:
+ Text("C")
+ case .struct:
+ Text("S")
+ case .enum:
+ Text("E")
+ case .actor:
+ Text("A")
+ case .protocol:
+ Text("Pr")
+ case .extension:
+ Text("Ex")
+ case .case:
+ Text("K")
+ case .property:
+ Text("P")
+ case .typealias:
+ Text("T")
+ case .function:
+ Text("𝑓")
+ case .method:
+ Text("M")
+ }
+ case .text:
+ Text("Txt")
+ case .webpage:
+ Text("Web")
+ case .other:
+ Text("*")
+ case .textFile:
+ Text("Txt")
+ case .error:
+ Text("Err")
+ }
+ }
+ .font(.system(size: 10).monospaced())
+ .foregroundColor(.white)
+ }
+ }
+ }
}
// MARK: - Previews
#Preview("Multiple Snippets") {
PromptToCodePanelView(store: .init(initialState: .init(
- promptToCodeState: Shared(PromptToCodeState(
+ promptToCodeState: Shared(ModificationState(
source: .init(
language: CodeLanguage.builtIn(.swift),
documentURL: URL(
@@ -736,7 +1105,7 @@ extension PromptToCodePanelView {
end: .init(line: 12, character: 2)
)
),
- ], instruction: "Previous instruction"),
+ ], instruction: .init("Previous instruction"), references: []),
],
snippets: [
.init(
@@ -770,12 +1139,22 @@ extension PromptToCodePanelView {
)
),
],
- instruction: "",
extraSystemPrompt: "",
- isAttachedToTarget: true
+ isAttachedToTarget: true,
+ references: [
+ ChatMessage.Reference(
+ title: "Foo",
+ content: "struct Foo { var foo: Int }",
+ kind: .symbol(
+ .struct,
+ uri: "file:///path/to/file.txt",
+ startLine: 13,
+ endLine: 13
+ )
+ ),
+ ],
)),
- indentSize: 4,
- usesTabsForIndentation: false,
+ instruction: nil,
commandName: "Generate Code"
), reducer: { PromptToCodePanel() }))
.frame(maxWidth: 450, maxHeight: Style.panelHeight)
@@ -785,7 +1164,7 @@ extension PromptToCodePanelView {
#Preview("Detached With Long File Name") {
PromptToCodePanelView(store: .init(initialState: .init(
- promptToCodeState: Shared(PromptToCodeState(
+ promptToCodeState: Shared(ModificationState(
source: .init(
language: CodeLanguage.builtIn(.swift),
documentURL: URL(
@@ -827,12 +1206,67 @@ extension PromptToCodePanelView {
)
),
],
- instruction: "",
extraSystemPrompt: "",
isAttachedToTarget: false
)),
- indentSize: 4,
- usesTabsForIndentation: false,
+ instruction: nil,
+ commandName: "Generate Code"
+ ), reducer: { PromptToCodePanel() }))
+ .frame(maxWidth: 450, maxHeight: Style.panelHeight)
+ .fixedSize(horizontal: false, vertical: true)
+ .frame(width: 500, height: 500, alignment: .center)
+}
+
+#Preview("Generating") {
+ PromptToCodePanelView(store: .init(initialState: .init(
+ promptToCodeState: Shared(ModificationState(
+ source: .init(
+ language: CodeLanguage.builtIn(.swift),
+ documentURL: URL(
+ fileURLWithPath: "path/to/file-name-is-super-long-what-should-we-do-with-it-hah.txt"
+ ),
+ projectRootURL: URL(fileURLWithPath: "path/to/file.txt"),
+ content: "",
+ lines: []
+ ),
+ snippets: [
+ .init(
+ startLineIndex: 8,
+ originalCode: "print(foo)",
+ modifiedCode: "print(bar)",
+ description: "",
+ error: "Error",
+ attachedRange: CursorRange(
+ start: .init(line: 8, character: 0),
+ end: .init(line: 12, character: 2)
+ )
+ ),
+ .init(
+ startLineIndex: 13,
+ originalCode: """
+ struct Bar {
+ var foo: Int
+ }
+ """,
+ modifiedCode: """
+ struct Bar {
+ var foo: String
+ }
+ """,
+ description: "Cool",
+ error: nil,
+ attachedRange: CursorRange(
+ start: .init(line: 13, character: 0),
+ end: .init(line: 12, character: 2)
+ )
+ ),
+ ],
+ extraSystemPrompt: "",
+ isAttachedToTarget: true,
+ isGenerating: true,
+ status: ["Status 1", "Status 2"]
+ )),
+ instruction: nil,
commandName: "Generate Code"
), reducer: { PromptToCodePanel() }))
.frame(maxWidth: 450, maxHeight: Style.panelHeight)
diff --git a/Core/Sources/SuggestionWidget/TextCursorTracker.swift b/Core/Sources/SuggestionWidget/TextCursorTracker.swift
index f73511a7..6de2dc29 100644
--- a/Core/Sources/SuggestionWidget/TextCursorTracker.swift
+++ b/Core/Sources/SuggestionWidget/TextCursorTracker.swift
@@ -1,9 +1,8 @@
-import Combine
import Foundation
import Perception
import SuggestionBasic
-import XcodeInspector
import SwiftUI
+import XcodeInspector
/// A passive tracker that observe the changes of the source editor content.
@Perceptible
@@ -29,8 +28,7 @@ final class TextCursorTracker {
lineAnnotations: []
)
- @PerceptionIgnored var editorObservationTask: Set = []
- @PerceptionIgnored var eventObservationTask: Task?
+ @PerceptionIgnored var eventObservationTask: Task?
init() {
observeAppChange()
@@ -39,37 +37,38 @@ final class TextCursorTracker {
deinit {
eventObservationTask?.cancel()
}
-
+
var isPreview: Bool {
ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
}
private func observeAppChange() {
if isPreview { return }
- editorObservationTask = []
- Task {
- await XcodeInspector.shared.safe.$focusedEditor.sink { [weak self] editor in
- guard let editor, let self else { return }
- Task { @MainActor in
- self.observeAXNotifications(editor)
- }
- }.store(in: &editorObservationTask)
+ Task { [weak self] in
+ let notifications = NotificationCenter.default
+ .notifications(named: .focusedEditorDidChange)
+ for await _ in notifications {
+ guard let self else { return }
+ guard let editor = await XcodeInspector.shared.focusedEditor else { continue }
+ await self.observeAXNotifications(editor)
+ }
}
}
- private func observeAXNotifications(_ editor: SourceEditor) {
+ private func observeAXNotifications(_ editor: SourceEditor) async {
if isPreview { return }
eventObservationTask?.cancel()
let content = editor.getLatestEvaluatedContent()
- Task { @MainActor in
+ await MainActor.run {
self.content = content
}
eventObservationTask = Task { [weak self] in
for await event in await editor.axNotifications.notifications() {
+ try Task.checkCancellation()
guard let self else { return }
guard event.kind == .evaluatedContentChanged else { continue }
let content = editor.getLatestEvaluatedContent()
- Task { @MainActor in
+ await MainActor.run {
self.content = content
}
}
@@ -87,3 +86,4 @@ extension EnvironmentValues {
set { self[TextCursorTrackerEnvironmentKey.self] = newValue }
}
}
+
diff --git a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift
index b7ceb487..5aed84b3 100644
--- a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift
+++ b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift
@@ -17,6 +17,7 @@ public struct WidgetLocation: Equatable {
enum UpdateLocationStrategy {
struct AlignToTextCursor {
func framesForWindows(
+ windowFrame: CGRect,
editorFrame: CGRect,
mainScreen: NSScreen,
activeScreen: NSScreen,
@@ -33,6 +34,7 @@ enum UpdateLocationStrategy {
)
else {
return FixedToBottom().framesForWindows(
+ windowFrame: windowFrame,
editorFrame: editorFrame,
mainScreen: mainScreen,
activeScreen: activeScreen,
@@ -43,6 +45,7 @@ enum UpdateLocationStrategy {
let found = AXValueGetValue(rect, .cgRect, &frame)
guard found else {
return FixedToBottom().framesForWindows(
+ windowFrame: windowFrame,
editorFrame: editorFrame,
mainScreen: mainScreen,
activeScreen: activeScreen,
@@ -51,6 +54,7 @@ enum UpdateLocationStrategy {
}
return HorizontalMovable().framesForWindows(
y: mainScreen.frame.height - frame.maxY,
+ windowFrame: windowFrame,
alignPanelTopToAnchor: nil,
editorFrame: editorFrame,
mainScreen: mainScreen,
@@ -63,6 +67,7 @@ enum UpdateLocationStrategy {
struct FixedToBottom {
func framesForWindows(
+ windowFrame: CGRect,
editorFrame: CGRect,
mainScreen: NSScreen,
activeScreen: NSScreen,
@@ -73,6 +78,7 @@ enum UpdateLocationStrategy {
) -> WidgetLocation {
var frames = HorizontalMovable().framesForWindows(
y: mainScreen.frame.height - editorFrame.maxY + Style.widgetPadding,
+ windowFrame: windowFrame,
alignPanelTopToAnchor: false,
editorFrame: editorFrame,
mainScreen: mainScreen,
@@ -97,6 +103,7 @@ enum UpdateLocationStrategy {
struct HorizontalMovable {
func framesForWindows(
y: CGFloat,
+ windowFrame: CGRect,
alignPanelTopToAnchor fixedAlignment: Bool?,
editorFrame: CGRect,
mainScreen: NSScreen,
@@ -130,6 +137,13 @@ enum UpdateLocationStrategy {
width: Style.widgetWidth,
height: Style.widgetHeight
)
+
+ let widgetFrame = CGRect(
+ x: windowFrame.minX,
+ y: mainScreen.frame.height - windowFrame.maxY + Style.indicatorBottomPadding,
+ width: Style.widgetWidth,
+ height: Style.widgetHeight
+ )
if !hideCircularWidget {
proposedAnchorFrameOnTheRightSide = widgetFrameOnTheRightSide
@@ -150,7 +164,7 @@ enum UpdateLocationStrategy {
x: proposedPanelX,
y: alignPanelTopToAnchor
? anchorFrame.maxY - Style.panelHeight
- : anchorFrame.minY - editorFrameExpendedSize.height,
+ : anchorFrame.minY,
width: Style.panelWidth,
height: Style.panelHeight
)
@@ -164,7 +178,7 @@ enum UpdateLocationStrategy {
)
return .init(
- widgetFrame: widgetFrameOnTheRightSide,
+ widgetFrame: widgetFrame,
tabFrame: tabFrame,
sharedPanelLocation: .init(
frame: panelFrame,
@@ -227,7 +241,7 @@ enum UpdateLocationStrategy {
height: Style.widgetHeight
)
return .init(
- widgetFrame: widgetFrameOnTheLeftSide,
+ widgetFrame: widgetFrame,
tabFrame: tabFrame,
sharedPanelLocation: .init(
frame: panelFrame,
@@ -244,10 +258,8 @@ enum UpdateLocationStrategy {
let panelFrame = CGRect(
x: anchorFrame.maxX - Style.panelWidth,
y: alignPanelTopToAnchor
- ? anchorFrame.maxY - Style.panelHeight - Style.widgetHeight
- - Style.widgetPadding
- : anchorFrame.maxY + Style.widgetPadding
- - editorFrameExpendedSize.height,
+ ? anchorFrame.maxY - Style.panelHeight
+ : anchorFrame.maxY - editorFrameExpendedSize.height,
width: Style.panelWidth,
height: Style.panelHeight
)
@@ -258,7 +270,7 @@ enum UpdateLocationStrategy {
height: Style.widgetHeight
)
return .init(
- widgetFrame: widgetFrameOnTheRightSide,
+ widgetFrame: widgetFrame,
tabFrame: tabFrame,
sharedPanelLocation: .init(
frame: panelFrame,
@@ -426,10 +438,6 @@ enum UpdateLocationStrategy {
let foundFirstLine = AXValueGetValue(selectedRange, .cfRange, &firstLineRange)
firstLineRange.length = 0
- #warning(
- "FIXME: When selection is too low and out of the screen, the selection range becomes something else."
- )
-
if foundFirstLine,
let firstLineSelectionRange = AXValueCreate(.cfRange, &firstLineRange),
let firstLineRect: AXValue = try? editor.copyParameterizedValue(
diff --git a/Core/Sources/SuggestionWidget/WidgetView.swift b/Core/Sources/SuggestionWidget/WidgetView.swift
index 23494685..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
+ }
}
}
}
@@ -140,12 +163,16 @@ struct WidgetContextMenu: View {
var body: some View {
WithPerceptionTracking {
Group { // Commands
- if !store.isChatOpen {
- Button(action: {
- store.send(.openChatButtonClicked)
- }) {
- Text("Open Chat")
- }
+ Button(action: {
+ store.send(.openChatButtonClicked)
+ }) {
+ Text("Open Chat")
+ }
+
+ Button(action: {
+ store.send(.openModificationButtonClicked)
+ }) {
+ Text("Write or Edit Code")
}
customCommandMenu()
@@ -263,6 +290,7 @@ struct WidgetView_Preview: PreviewProvider {
),
isHovering: false
)
+ .frame(width: Style.widgetWidth, height: Style.widgetHeight)
WidgetView(
store: Store(
@@ -277,6 +305,7 @@ struct WidgetView_Preview: PreviewProvider {
),
isHovering: true
)
+ .frame(width: Style.widgetWidth, height: Style.widgetHeight)
WidgetView(
store: Store(
@@ -291,6 +320,7 @@ struct WidgetView_Preview: PreviewProvider {
),
isHovering: false
)
+ .frame(width: Style.widgetWidth, height: Style.widgetHeight)
WidgetView(
store: Store(
@@ -305,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 6d0bb0e2..2f70e0e3 100644
--- a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift
+++ b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift
@@ -1,11 +1,11 @@
import AppKit
import AsyncAlgorithms
import ChatTab
-import Combine
import ComposableArchitecture
import Dependencies
import Foundation
import SharedUIComponents
+import SwiftNavigation
import SwiftUI
import XcodeInspector
@@ -17,13 +17,12 @@ actor WidgetWindowsController: NSObject {
let userDefaultsObservers = WidgetUserDefaultsObservers()
var xcodeInspector: XcodeInspector { .shared }
- let windows: WidgetWindows
- let store: StoreOf
- let chatTabPool: ChatTabPool
+ nonisolated let windows: WidgetWindows
+ nonisolated let store: StoreOf
+ nonisolated let chatTabPool: ChatTabPool
var currentApplicationProcessIdentifier: pid_t?
- var cancellable: Set = []
var observeToAppTask: Task?
var observeToFocusedEditorTask: Task?
@@ -56,23 +55,30 @@ actor WidgetWindowsController: NSObject {
}
func start() {
- cancellable.removeAll()
-
- xcodeInspector.$activeApplication.sink { [weak self] app in
- guard let app else { return }
- Task { [weak self] in await self?.activate(app) }
- }.store(in: &cancellable)
+ Task { [xcodeInspector] in
+ await observe { [weak self] in
+ if let app = xcodeInspector.activeApplication {
+ Task {
+ await self?.activate(app)
+ }
+ }
+ }
- xcodeInspector.$focusedEditor.sink { [weak self] editor in
- guard let editor else { return }
- Task { [weak self] in await self?.observe(toEditor: editor) }
- }.store(in: &cancellable)
+ await observe { [weak self] in
+ if let editor = xcodeInspector.focusedEditor {
+ Task {
+ await self?.observe(toEditor: editor)
+ }
+ }
+ }
- xcodeInspector.$completionPanel.sink { [weak self] newValue in
- Task { [weak self] in
- await self?.handleCompletionPanelChange(isDisplaying: newValue != nil)
+ await observe { [weak self] in
+ let isDisplaying = xcodeInspector.completionPanel != nil
+ Task {
+ await self?.handleCompletionPanelChange(isDisplaying: isDisplaying)
+ }
}
- }.store(in: &cancellable)
+ }
userDefaultsObservers.presentationModeChangeObserver.onChange = { [weak self] in
Task { [weak self] in
@@ -97,6 +103,10 @@ actor WidgetWindowsController: NSObject {
}
}
}
+
+ Task { @MainActor in
+ windows.chatPanelWindow.isPanelDisplayed = false
+ }
}
}
@@ -114,6 +124,7 @@ private extension WidgetWindowsController {
await hideSuggestionPanelWindow()
}
await adjustChatPanelWindowLevel()
+ await adjustModificationPanelLevel()
}
guard currentApplicationProcessIdentifier != app.processIdentifier else { return }
currentApplicationProcessIdentifier = app.processIdentifier
@@ -127,36 +138,38 @@ private extension WidgetWindowsController {
observeToAppTask = Task {
await windows.orderFront()
- for await notification in await notifications.notifications() {
- try Task.checkCancellation()
-
- /// Hide the widgets before switching to another window/editor
- /// so the transition looks better.
- func hideWidgetForTransitions() async {
- let newDocumentURL = await xcodeInspector.safe.realtimeActiveDocumentURL
- let documentURL = await MainActor
- .run { store.withState { $0.focusingDocumentURL } }
- if documentURL != newDocumentURL {
- await send(.panel(.removeDisplayedContent))
- await hidePanelWindows()
- }
- await send(.updateFocusingDocumentURL)
- }
-
- func removeContent() async {
+ /// Hide the widgets before switching to another window/editor
+ /// so the transition looks better.
+ func hideWidgetForTransitions() async {
+ let newDocumentURL = xcodeInspector.realtimeActiveDocumentURL
+ let documentURL = await MainActor
+ .run { store.withState { $0.focusingDocumentURL } }
+ if documentURL != newDocumentURL {
await send(.panel(.removeDisplayedContent))
+ await hidePanelWindows()
}
+ await send(.updateFocusingDocumentURL)
+ }
- func updateWidgetsAndNotifyChangeOfEditor(immediately: Bool) async {
- await send(.panel(.switchToAnotherEditorAndUpdateContent))
- updateWindowLocation(animated: false, immediately: immediately)
- updateWindowOpacity(immediately: immediately)
- }
+ func removeContent() async {
+ await send(.panel(.removeDisplayedContent))
+ }
- func updateWidgets(immediately: Bool) async {
- updateWindowLocation(animated: false, immediately: immediately)
- updateWindowOpacity(immediately: immediately)
- }
+ func updateWidgetsAndNotifyChangeOfEditor(immediately: Bool) async {
+ await send(.panel(.switchToAnotherEditorAndUpdateContent))
+ updateWindowLocation(animated: false, immediately: immediately)
+ updateWindowOpacity(immediately: immediately)
+ }
+
+ func updateWidgets(immediately: Bool) async {
+ updateWindowLocation(animated: false, immediately: immediately)
+ updateWindowOpacity(immediately: immediately)
+ }
+
+ await updateWidgetsAndNotifyChangeOfEditor(immediately: true)
+
+ for await notification in await notifications.notifications() {
+ try Task.checkCancellation()
switch notification.kind {
case .focusedWindowChanged:
@@ -202,7 +215,7 @@ private extension WidgetWindowsController {
selectionRangeChange.debounce(for: Duration.milliseconds(500)),
scroll
) {
- guard await xcodeInspector.safe.latestActiveXcode != nil else { return }
+ guard await xcodeInspector.latestActiveXcode != nil else { return }
try Task.checkCancellation()
// for better looking
@@ -215,7 +228,7 @@ private extension WidgetWindowsController {
}
} else {
for await notification in merge(selectionRangeChange, scroll) {
- guard await xcodeInspector.safe.latestActiveXcode != nil else { return }
+ guard await xcodeInspector.latestActiveXcode != nil else { return }
try Task.checkCancellation()
// for better looking
@@ -252,7 +265,7 @@ private extension WidgetWindowsController {
extension WidgetWindowsController {
@MainActor
func hidePanelWindows() {
- windows.sharedPanelWindow.alphaValue = 0
+// windows.sharedPanelWindow.alphaValue = 0
windows.suggestionPanelWindow.alphaValue = 0
}
@@ -261,13 +274,20 @@ extension WidgetWindowsController {
windows.suggestionPanelWindow.alphaValue = 0
}
- func generateWidgetLocation() -> WidgetLocation? {
- if let application = xcodeInspector.latestActiveXcode?.appElement {
- if let focusElement = xcodeInspector.focusedEditor?.element,
+ func generateWidgetLocation() async -> WidgetLocation? {
+ if let application = await xcodeInspector.latestActiveXcode?.appElement {
+ if let window = application.focusedWindow,
+ let windowFrame = window.rect,
+ let focusElement = await xcodeInspector.focusedEditor?.element,
let parent = focusElement.parent,
let frame = parent.rect,
- let screen = NSScreen.screens.first(where: { $0.frame.origin == .zero }),
- let firstScreen = NSScreen.main
+ let screen = NSScreen.screens.first(
+ where: { $0.frame.origin == .zero }
+ ) ?? NSScreen.main,
+ let windowContainingScreen = NSScreen.screens.first(where: {
+ let flippedScreenFrame = $0.frame.flipped(relativeTo: screen.frame)
+ return flippedScreenFrame.contains(frame.origin)
+ })
{
let positionMode = UserDefaults.shared
.value(for: \.suggestionWidgetPositionMode)
@@ -277,19 +297,21 @@ extension WidgetWindowsController {
switch positionMode {
case .fixedToBottom:
var result = UpdateLocationStrategy.FixedToBottom().framesForWindows(
+ windowFrame: windowFrame,
editorFrame: frame,
mainScreen: screen,
- activeScreen: firstScreen
+ activeScreen: windowContainingScreen
)
switch suggestionMode {
case .nearbyTextCursor:
result.suggestionPanelLocation = UpdateLocationStrategy
.NearbyTextCursor()
.framesForSuggestionWindow(
- editorFrame: frame, mainScreen: screen,
- activeScreen: firstScreen,
+ editorFrame: frame,
+ mainScreen: screen,
+ activeScreen: windowContainingScreen,
editor: focusElement,
- completionPanel: xcodeInspector.completionPanel
+ completionPanel: await xcodeInspector.completionPanel
)
default:
break
@@ -297,9 +319,10 @@ extension WidgetWindowsController {
return result
case .alignToTextCursor:
var result = UpdateLocationStrategy.AlignToTextCursor().framesForWindows(
+ windowFrame: windowFrame,
editorFrame: frame,
mainScreen: screen,
- activeScreen: firstScreen,
+ activeScreen: windowContainingScreen,
editor: focusElement
)
switch suggestionMode {
@@ -308,9 +331,9 @@ extension WidgetWindowsController {
.NearbyTextCursor()
.framesForSuggestionWindow(
editorFrame: frame, mainScreen: screen,
- activeScreen: firstScreen,
+ activeScreen: windowContainingScreen,
editor: focusElement,
- completionPanel: xcodeInspector.completionPanel
+ completionPanel: await xcodeInspector.completionPanel
)
default:
break
@@ -339,29 +362,18 @@ extension WidgetWindowsController {
defaultPanelLocation: .init(frame: .zero, alignPanelTop: false)
)
}
-
+
window = workspaceWindow
frame = rect
}
- var expendedSize = CGSize.zero
- if ["Xcode.WorkspaceWindow"].contains(window.identifier) {
- // extra padding to bottom so buttons won't be covered
- frame.size.height -= 40
- } else {
- // move a bit away from the window so buttons won't be covered
- frame.origin.x -= Style.widgetPadding + Style.widgetWidth / 2
- frame.size.width += Style.widgetPadding * 2 + Style.widgetWidth
- expendedSize.width = (Style.widgetPadding * 2 + Style.widgetWidth) / 2
- expendedSize.height += Style.widgetPadding
- }
-
return UpdateLocationStrategy.FixedToBottom().framesForWindows(
+ windowFrame: frame,
editorFrame: frame,
mainScreen: screen,
activeScreen: firstScreen,
preferredInsideEditorMinWidth: 9_999_999_999, // never
- editorFrameExpendedSize: expendedSize
+ editorFrameExpendedSize: .zero
)
}
}
@@ -384,28 +396,18 @@ extension WidgetWindowsController {
}
try Task.checkCancellation()
let xcodeInspector = self.xcodeInspector
- let activeApp = await xcodeInspector.safe.activeApplication
- let latestActiveXcode = await xcodeInspector.safe.latestActiveXcode
- let previousActiveApplication = xcodeInspector.previousActiveApplication
+ let activeApp = await xcodeInspector.activeApplication
+ let latestActiveXcode = await xcodeInspector.latestActiveXcode
+ let previousActiveApplication = await xcodeInspector.previousActiveApplication
await MainActor.run {
- let state = store.withState { $0 }
- let isChatPanelDetached = state.chatPanelState.isDetached
- let hasChat = !state.chatPanelState.chatTabGroup.tabInfo.isEmpty
-
if let activeApp, activeApp.isXcode {
let application = activeApp.appElement
/// We need this to hide the windows when Xcode is minimized.
let noFocus = application.focusedWindow == nil
- windows.sharedPanelWindow.alphaValue = noFocus ? 0 : 1
+ windows.sharedPanelWindow.alphaValue = 1
windows.suggestionPanelWindow.alphaValue = noFocus ? 0 : 1
windows.widgetWindow.alphaValue = noFocus ? 0 : 1
windows.toastWindow.alphaValue = noFocus ? 0 : 1
-
- if isChatPanelDetached {
- windows.chatPanelWindow.isWindowHidden = false
- } else {
- windows.chatPanelWindow.isWindowHidden = noFocus
- }
} else if let activeApp, activeApp.isExtensionService {
let noFocus = {
guard let xcode = latestActiveXcode else { return true }
@@ -419,7 +421,7 @@ extension WidgetWindowsController {
let previousAppIsXcode = previousActiveApplication?.isXcode ?? false
- windows.sharedPanelWindow.alphaValue = noFocus ? 0 : 1
+ windows.sharedPanelWindow.alphaValue = 1
windows.suggestionPanelWindow.alphaValue = noFocus ? 0 : 1
windows.widgetWindow.alphaValue = if noFocus {
0
@@ -435,20 +437,11 @@ extension WidgetWindowsController {
0
}
windows.toastWindow.alphaValue = noFocus ? 0 : 1
- if isChatPanelDetached {
- windows.chatPanelWindow.isWindowHidden = false
- } else {
- windows.chatPanelWindow.isWindowHidden = noFocus && !windows
- .chatPanelWindow.isKeyWindow
- }
} else {
- windows.sharedPanelWindow.alphaValue = 0
+ windows.sharedPanelWindow.alphaValue = 1
windows.suggestionPanelWindow.alphaValue = 0
windows.widgetWindow.alphaValue = 0
windows.toastWindow.alphaValue = 0
- if !isChatPanelDetached {
- windows.chatPanelWindow.isWindowHidden = true
- }
}
}
}
@@ -504,6 +497,7 @@ extension WidgetWindowsController {
}
await adjustChatPanelWindowLevel()
+ await adjustModificationPanelLevel()
}
let now = Date()
@@ -532,39 +526,49 @@ extension WidgetWindowsController {
lastUpdateWindowLocationTime = Date()
}
+ @MainActor
+ func adjustModificationPanelLevel() async {
+ let window = windows.sharedPanelWindow
+
+ let latestApp = await xcodeInspector.activeApplication
+ let latestAppIsXcodeOrExtension = if let latestApp {
+ latestApp.isXcode || latestApp.isExtensionService
+ } else {
+ false
+ }
+
+ window.setFloatOnTop(latestAppIsXcodeOrExtension)
+ }
+
@MainActor
func adjustChatPanelWindowLevel() async {
+ let flowOnTopOption = UserDefaults.shared
+ .value(for: \.chatPanelFloatOnTopOption)
let disableFloatOnTopWhenTheChatPanelIsDetached = UserDefaults.shared
.value(for: \.disableFloatOnTopWhenTheChatPanelIsDetached)
let window = windows.chatPanelWindow
- guard disableFloatOnTopWhenTheChatPanelIsDetached else {
- window.setFloatOnTop(true)
+
+ if flowOnTopOption == .never {
+ window.setFloatOnTop(false)
return
}
let state = store.withState { $0 }
let isChatPanelDetached = state.chatPanelState.isDetached
- guard isChatPanelDetached else {
- window.setFloatOnTop(true)
- return
- }
-
let floatOnTopWhenOverlapsXcode = UserDefaults.shared
.value(for: \.keepFloatOnTopIfChatPanelAndXcodeOverlaps)
- let latestApp = await xcodeInspector.safe.activeApplication
+ let latestApp = await xcodeInspector.activeApplication
let latestAppIsXcodeOrExtension = if let latestApp {
latestApp.isXcode || latestApp.isExtensionService
} else {
false
}
- if !floatOnTopWhenOverlapsXcode || !latestAppIsXcodeOrExtension {
- window.setFloatOnTop(false)
- } else {
- guard let xcode = await xcodeInspector.safe.latestActiveXcode else { return }
+ async let overlap: Bool = { @MainActor in
+ guard let xcode = await xcodeInspector.latestActiveXcode else { return false }
let windowElements = xcode.appElement.windows
let overlap = windowElements.contains {
if let position = $0.position, let size = $0.size {
@@ -578,25 +582,46 @@ extension WidgetWindowsController {
}
return false
}
+ return overlap
+ }()
- window.setFloatOnTop(overlap)
+ if latestAppIsXcodeOrExtension {
+ if floatOnTopWhenOverlapsXcode {
+ let overlap = await overlap
+ window.setFloatOnTop(overlap)
+ } else {
+ if disableFloatOnTopWhenTheChatPanelIsDetached, isChatPanelDetached {
+ window.setFloatOnTop(false)
+ } else {
+ window.setFloatOnTop(true)
+ }
+ }
+ } else {
+ if floatOnTopWhenOverlapsXcode {
+ let overlap = await overlap
+ window.setFloatOnTop(overlap)
+ } else {
+ switch flowOnTopOption {
+ case .onTopWhenXcodeIsActive:
+ window.setFloatOnTop(false)
+ case .alwaysOnTop:
+ window.setFloatOnTop(true)
+ case .never:
+ window.setFloatOnTop(false)
+ }
+ }
}
}
@MainActor
func handleSpaceChange() async {
- let activeXcode = await XcodeInspector.shared.safe.activeXcode
+ let activeXcode = XcodeInspector.shared.activeXcode
let xcode = activeXcode?.appElement
- let isFullscreen = if let xcode, let xcodeWindow = xcode.focusedWindow {
- xcodeWindow.isFullScreen && xcode.isFrontmost
- } else {
- false
- }
-
+
let isXcodeActive = xcode?.isFrontmost ?? false
- await [
+ [
windows.sharedPanelWindow,
windows.suggestionPanelWindow,
windows.widgetWindow,
@@ -606,13 +631,13 @@ extension WidgetWindowsController {
$0.moveToActiveSpace()
}
}
-
+
if isXcodeActive, !windows.chatPanelWindow.isDetached {
- await windows.chatPanelWindow.moveToActiveSpace()
+ windows.chatPanelWindow.moveToActiveSpace()
}
- if await windows.fullscreenDetector.isOnActiveSpace, xcode?.focusedWindow != nil {
- await windows.orderFront()
+ if windows.fullscreenDetector.isOnActiveSpace, xcode?.focusedWindow != nil {
+ windows.orderFront()
}
}
}
@@ -679,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
@@ -697,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(
@@ -723,10 +746,10 @@ public final class WidgetWindows {
defer: false
)
it.isReleasedWhenClosed = false
- it.isOpaque = false
it.backgroundColor = .clear
it.level = widgetLevel(2)
- it.hasShadow = true
+ it.hoveringLevel = widgetLevel(2)
+ it.hasShadow = false
it.contentView = NSHostingView(
rootView: SharedPanelView(
store: store.scope(
@@ -741,7 +764,7 @@ public final class WidgetWindows {
it.setIsVisible(true)
it.canBecomeKeyChecker = { [store] in
store.withState { state in
- state.panelState.sharedPanelState.content.promptToCode != nil
+ !state.panelState.sharedPanelState.content.promptToCodeGroup.promptToCodes.isEmpty
}
}
return it
@@ -756,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(
@@ -788,6 +812,7 @@ public final class WidgetWindows {
self?.store.send(.chatPanel(.hideButtonClicked))
}
)
+ it.hoveringLevel = widgetLevel(1)
it.delegate = controller
return it
}()
@@ -795,7 +820,7 @@ public final class WidgetWindows {
@MainActor
lazy var toastWindow = {
let it = WidgetWindow(
- contentRect: .zero,
+ contentRect: .init(x: 0, y: 0, width: Style.panelWidth, height: Style.panelHeight),
styleMask: [.borderless],
backing: .buffered,
defer: false
@@ -852,6 +877,10 @@ class WidgetWindow: CanBecomeKeyWindow {
case switchingSpace
}
+ var hoveringLevel: NSWindow.Level = widgetLevel(0)
+
+ override var isFloatingPanel: Bool { true }
+
var defaultCollectionBehavior: NSWindow.CollectionBehavior {
[.fullScreenAuxiliary, .transient]
}
@@ -873,7 +902,7 @@ class WidgetWindow: CanBecomeKeyWindow {
}
}
}
-
+
func moveToActiveSpace() {
let previousState = state
state = .switchingSpace
@@ -882,11 +911,44 @@ class WidgetWindow: CanBecomeKeyWindow {
self.state = previousState
}
}
+
+ func setFloatOnTop(_ isFloatOnTop: Bool) {
+ let targetLevel: NSWindow.Level = isFloatOnTop
+ ? hoveringLevel
+ : .normal
+
+ if targetLevel != level {
+ orderFrontRegardless()
+ level = targetLevel
+ }
+ }
}
func widgetLevel(_ addition: Int) -> NSWindow.Level {
let minimumWidgetLevel: Int
+ #if DEBUG
+ minimumWidgetLevel = NSWindow.Level.floating.rawValue + 1
+ #else
minimumWidgetLevel = NSWindow.Level.floating.rawValue
+ #endif
return .init(minimumWidgetLevel + addition)
}
+extension CGRect {
+ func flipped(relativeTo reference: CGRect) -> CGRect {
+ let flippedOrigin = CGPoint(
+ x: origin.x,
+ y: reference.height - origin.y - height
+ )
+ return CGRect(origin: flippedOrigin, size: size)
+ }
+
+ func relative(to reference: CGRect) -> CGRect {
+ let relativeOrigin = CGPoint(
+ x: origin.x - reference.origin.x,
+ y: origin.y - reference.origin.y
+ )
+ return CGRect(origin: relativeOrigin, size: size)
+ }
+}
+
diff --git a/Core/Sources/XcodeThemeController/XcodeThemeController.swift b/Core/Sources/XcodeThemeController/XcodeThemeController.swift
index 5cff0ddd..01118547 100644
--- a/Core/Sources/XcodeThemeController/XcodeThemeController.swift
+++ b/Core/Sources/XcodeThemeController/XcodeThemeController.swift
@@ -157,8 +157,9 @@ extension XcodeThemeController {
}
let xcodeURL: URL? = {
- // Use the latest running Xcode
- if let running = XcodeInspector.shared.latestActiveXcode?.bundleURL {
+ if let running = NSWorkspace.shared
+ .urlForApplication(withBundleIdentifier: "com.apple.dt.Xcode")
+ {
return running
}
// Use the main Xcode.app
diff --git a/Core/Sources/XcodeThemeController/XcodeThemeParser.swift b/Core/Sources/XcodeThemeController/XcodeThemeParser.swift
index 80b13ed5..b2a3cd53 100644
--- a/Core/Sources/XcodeThemeController/XcodeThemeParser.swift
+++ b/Core/Sources/XcodeThemeController/XcodeThemeParser.swift
@@ -113,7 +113,7 @@ struct XcodeThemeParser {
guard let theme = plist else { throw Error.invalidData }
/// The source value is an `r g b a` string, for example: `0.5 0.5 0.2 1`
- func converColor(source: String) -> XcodeTheme.ThemeColor {
+ func convertColor(source: String) -> XcodeTheme.ThemeColor {
let components = source.split(separator: " ")
let red = (components[0] as NSString).doubleValue
let green = (components[1] as NSString).doubleValue
@@ -136,7 +136,7 @@ struct XcodeThemeParser {
currentDict = value
}
if let value = currentDict[path.last!] as? String {
- return converColor(source: value)
+ return convertColor(source: value)
}
return defaultValue
}
diff --git a/Core/Tests/PromptToCodeServiceTests/ExtractCodeFromChatGPTTests.swift b/Core/Tests/PromptToCodeServiceTests/ExtractCodeFromChatGPTTests.swift
index 27bb7075..9105ec2f 100644
--- a/Core/Tests/PromptToCodeServiceTests/ExtractCodeFromChatGPTTests.swift
+++ b/Core/Tests/PromptToCodeServiceTests/ExtractCodeFromChatGPTTests.swift
@@ -3,7 +3,7 @@ import XCTest
final class ExtractCodeFromChatGPTTests: XCTestCase {
func test_extract_from_no_code_block() {
- let api = OpenAIPromptToCodeService()
+ let api = SimpleModificationAgent()
let result = api.extractCodeAndDescription(from: """
hello world!
""")
@@ -13,7 +13,7 @@ final class ExtractCodeFromChatGPTTests: XCTestCase {
}
func test_extract_from_incomplete_code_block() {
- let api = OpenAIPromptToCodeService()
+ let api = SimpleModificationAgent()
let result = api.extractCodeAndDescription(from: """
```swift
func foo() {}
@@ -24,7 +24,7 @@ final class ExtractCodeFromChatGPTTests: XCTestCase {
}
func test_extract_from_complete_code_block() {
- let api = OpenAIPromptToCodeService()
+ let api = SimpleModificationAgent()
let result = api.extractCodeAndDescription(from: """
```swift
func foo() {}
@@ -40,7 +40,7 @@ final class ExtractCodeFromChatGPTTests: XCTestCase {
}
func test_extract_from_incomplete_code_block_without_language() {
- let api = OpenAIPromptToCodeService()
+ let api = SimpleModificationAgent()
let result = api.extractCodeAndDescription(from: """
```
func foo() {}
@@ -51,7 +51,7 @@ final class ExtractCodeFromChatGPTTests: XCTestCase {
}
func test_extract_from_code_block_without_language() {
- let api = OpenAIPromptToCodeService()
+ let api = SimpleModificationAgent()
let result = api.extractCodeAndDescription(from: """
```
func foo() {}
diff --git a/Core/Tests/ServiceTests/Environment.swift b/Core/Tests/ServiceTests/Environment.swift
index ee78f322..13a66210 100644
--- a/Core/Tests/ServiceTests/Environment.swift
+++ b/Core/Tests/ServiceTests/Environment.swift
@@ -14,6 +14,10 @@ func completion(text: String, range: CursorRange, uuid: String = "") -> CodeSugg
}
class MockSuggestionService: GitHubCopilotSuggestionServiceType {
+ func cancelOngoingTask(workDoneToken: String) async {
+ fatalError()
+ }
+
func notifyChangeTextDocument(fileURL: URL, content: String, version: Int) async throws {
fatalError()
}
diff --git a/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift b/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift
index b12550f5..44ae7129 100644
--- a/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift
+++ b/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift
@@ -14,8 +14,10 @@ class FilespaceSuggestionInvalidationTests: XCTestCase {
range: CursorRange
) async throws -> Filespace {
let pool = WorkspacePool()
- let (_, filespace) = try await pool
- .fetchOrCreateWorkspaceAndFilespace(fileURL: URL(fileURLWithPath: "file/path/to.swift"))
+ let (_, filespace) = try await pool.fetchOrCreateWorkspaceAndFilespace(
+ fileURL: URL(fileURLWithPath: "file/path/to.swift"),
+ checkIfFileExists: false
+ )
filespace.suggestions = [
.init(
id: "",
@@ -120,7 +122,7 @@ class FilespaceSuggestionInvalidationTests: XCTestCase {
let suggestion = filespace.presentingSuggestion
XCTAssertNotNil(suggestion)
}
-
+
func test_typing_not_according_to_suggestion_should_invalidate() async throws {
let lines = ["\n", "hello ma\n", "\n"]
let filespace = try await prepare(
diff --git a/EditorExtension/AcceptSuggestionCommand.swift b/EditorExtension/AcceptSuggestionCommand.swift
index a1ea71f6..cbca64ed 100644
--- a/EditorExtension/AcceptSuggestionCommand.swift
+++ b/EditorExtension/AcceptSuggestionCommand.swift
@@ -1,6 +1,6 @@
import Client
-import SuggestionBasic
import Foundation
+import SuggestionBasic
import XcodeKit
import XPCShared
@@ -31,3 +31,30 @@ class AcceptSuggestionCommand: NSObject, XCSourceEditorCommand, CommandType {
}
}
+class AcceptSuggestionLineCommand: NSObject, XCSourceEditorCommand, CommandType {
+ var name: String { "Accept Suggestion Line" }
+
+ func perform(
+ with invocation: XCSourceEditorCommandInvocation,
+ completionHandler: @escaping (Error?) -> Void
+ ) {
+ Task {
+ do {
+ try await (Task(timeout: 7) {
+ let service = try getService()
+ if let content = try await service.send(
+ requestBody: ExtensionServiceRequests
+ .GetSuggestionLineAcceptedCode(editorContent: .init(invocation))
+ ) {
+ invocation.accept(content)
+ }
+ completionHandler(nil)
+ }.value)
+ } catch is CancellationError {
+ completionHandler(nil)
+ } catch {
+ completionHandler(error)
+ }
+ }
+ }
+}
diff --git a/EditorExtension/PromptToCodeCommand.swift b/EditorExtension/PromptToCodeCommand.swift
index e7086d57..a2f814ac 100644
--- a/EditorExtension/PromptToCodeCommand.swift
+++ b/EditorExtension/PromptToCodeCommand.swift
@@ -4,7 +4,7 @@ import Foundation
import XcodeKit
class PromptToCodeCommand: NSObject, XCSourceEditorCommand, CommandType {
- var name: String { "Write or Modify Code" }
+ var name: String { "Write or Edit Code" }
func perform(
with invocation: XCSourceEditorCommandInvocation,
diff --git a/EditorExtension/SourceEditorExtension.swift b/EditorExtension/SourceEditorExtension.swift
index aedf6bf3..f102f9d4 100644
--- a/EditorExtension/SourceEditorExtension.swift
+++ b/EditorExtension/SourceEditorExtension.swift
@@ -12,6 +12,7 @@ class SourceEditorExtension: NSObject, XCSourceEditorExtension {
[
GetSuggestionsCommand(),
AcceptSuggestionCommand(),
+ AcceptSuggestionLineCommand(),
RejectSuggestionCommand(),
NextSuggestionCommand(),
PreviousSuggestionCommand(),
diff --git a/ExtensionService/AppDelegate+Menu.swift b/ExtensionService/AppDelegate+Menu.swift
index e5567c27..9107c97a 100644
--- a/ExtensionService/AppDelegate+Menu.swift
+++ b/ExtensionService/AppDelegate+Menu.swift
@@ -2,6 +2,8 @@ import AppKit
import Foundation
import Preferences
import XcodeInspector
+import Dependencies
+import Workspace
extension AppDelegate {
fileprivate var statusBarMenuIdentifier: NSUserInterfaceItemIdentifier {
@@ -95,6 +97,12 @@ extension AppDelegate {
action: #selector(reactivateObservationsToXcode),
keyEquivalent: ""
)
+
+ let resetWorkspacesItem = NSMenuItem(
+ title: "Reset workspaces",
+ action: #selector(destroyWorkspacePool),
+ keyEquivalent: ""
+ )
reactivateObservationsItem.target = self
@@ -108,6 +116,7 @@ extension AppDelegate {
statusBarMenu.addItem(xcodeInspectorDebug)
statusBarMenu.addItem(accessibilityAPIPermission)
statusBarMenu.addItem(reactivateObservationsItem)
+ statusBarMenu.addItem(resetWorkspacesItem)
statusBarMenu.addItem(quitItem)
statusBarMenu.delegate = self
@@ -160,7 +169,7 @@ extension AppDelegate: NSMenuDelegate {
menu.items.append(.text("Focused Element: N/A"))
}
- if let sourceEditor = inspector.focusedEditor {
+ if let sourceEditor = inspector.latestFocusedEditor {
let label = sourceEditor.element.description
menu.items
.append(.text("Active Source Editor: \(label.isEmpty ? "Unknown" : label)"))
@@ -217,6 +226,15 @@ extension AppDelegate: NSMenuDelegate {
action: #selector(restartXcodeInspector),
keyEquivalent: ""
))
+
+ let isDebuggingOverlay = UserDefaults.shared.value(for: \.debugOverlayPanel)
+ let debugOverlayItem = NSMenuItem(
+ title: "Debug Window Overlays",
+ action: #selector(toggleDebugOverlayPanel),
+ keyEquivalent: ""
+ )
+ debugOverlayItem.state = isDebuggingOverlay ? .on : .off
+ menu.items.append(debugOverlayItem)
default:
break
@@ -234,17 +252,33 @@ private extension AppDelegate {
}
@objc func reactivateObservationsToXcode() {
- XcodeInspector.shared.reactivateObservationsToXcode()
+ Task {
+ await XcodeInspector.shared.reactivateObservationsToXcode()
+ }
}
@objc func openExtensionManager() {
guard let data = try? JSONEncoder().encode(ExtensionServiceRequests.OpenExtensionManager())
else { return }
- service.handleXPCServiceRequests(
- endpoint: ExtensionServiceRequests.OpenExtensionManager.endpoint,
- requestBody: data,
- reply: { _, _ in }
- )
+ Task {
+ await service.handleXPCServiceRequests(
+ endpoint: ExtensionServiceRequests.OpenExtensionManager.endpoint,
+ requestBody: data,
+ reply: { _, _ in }
+ )
+ }
+ }
+
+ @objc func destroyWorkspacePool() {
+ @Dependency(\.workspacePool) var workspacePool: WorkspacePool
+ Task {
+ await workspacePool.destroy()
+ }
+ }
+
+ @objc func toggleDebugOverlayPanel() {
+ let isDebuggingOverlay = UserDefaults.shared.value(for: \.debugOverlayPanel)
+ UserDefaults.shared.set(!isDebuggingOverlay, for: \.debugOverlayPanel)
}
}
diff --git a/ExtensionService/AppDelegate.swift b/ExtensionService/AppDelegate.swift
index 1d107672..801ce37f 100644
--- a/ExtensionService/AppDelegate.swift
+++ b/ExtensionService/AppDelegate.swift
@@ -1,6 +1,7 @@
import FileChangeChecker
import LaunchAgentManager
import Logger
+import Perception
import Preferences
import Service
import ServiceManagement
@@ -29,6 +30,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
)
func applicationDidFinishLaunching(_: Notification) {
+// isPerceptionCheckingEnabled = false
if ProcessInfo.processInfo.environment["IS_UNIT_TEST"] == "YES" { return }
_ = XcodeInspector.shared
updateChecker.updateCheckerDelegate = self
diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon.png
deleted file mode 100644
index 291eaac7..00000000
Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon.png and /dev/null differ
diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@128w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@128w.png
deleted file mode 100644
index 160db273..00000000
Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@128w.png and /dev/null differ
diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@16w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@16w.png
deleted file mode 100644
index 4fcd6278..00000000
Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@16w.png and /dev/null differ
diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w 1.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w 1.png
deleted file mode 100644
index e31a8d3b..00000000
Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w 1.png and /dev/null differ
diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w.png
deleted file mode 100644
index e31a8d3b..00000000
Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w.png and /dev/null differ
diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w 1.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w 1.png
deleted file mode 100644
index ec264755..00000000
Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w 1.png and /dev/null differ
diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w.png
deleted file mode 100644
index ec264755..00000000
Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w.png and /dev/null differ
diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w 1.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w 1.png
deleted file mode 100644
index 4b760bc1..00000000
Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w 1.png and /dev/null differ
diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w.png
deleted file mode 100644
index 4b760bc1..00000000
Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w.png and /dev/null differ
diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@64w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@64w.png
deleted file mode 100644
index 8d777985..00000000
Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@64w.png and /dev/null differ
diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/Contents.json b/ExtensionService/Assets.xcassets/AppIcon.appiconset/Contents.json
index 56acb569..d3a89dc6 100644
--- a/ExtensionService/Assets.xcassets/AppIcon.appiconset/Contents.json
+++ b/ExtensionService/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -1,61 +1,61 @@
{
"images" : [
{
- "filename" : "1024 x 1024 your icon@16w.png",
+ "filename" : "service-icon@16w.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
- "filename" : "1024 x 1024 your icon@32w 1.png",
+ "filename" : "service-icon@32w.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
- "filename" : "1024 x 1024 your icon@32w.png",
+ "filename" : "service-icon@32w.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
- "filename" : "1024 x 1024 your icon@64w.png",
+ "filename" : "service-icon@64w.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
- "filename" : "1024 x 1024 your icon@128w.png",
+ "filename" : "service-icon@128w.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
- "filename" : "1024 x 1024 your icon@256w 1.png",
+ "filename" : "service-icon@256w.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
- "filename" : "1024 x 1024 your icon@256w.png",
+ "filename" : "service-icon@256w.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
- "filename" : "1024 x 1024 your icon@512w 1.png",
+ "filename" : "service-icon@512w.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
- "filename" : "1024 x 1024 your icon@512w.png",
+ "filename" : "service-icon@512w.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
- "filename" : "1024 x 1024 your icon.png",
+ "filename" : "service-icon.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon.png
new file mode 100644
index 00000000..29782a0f
Binary files /dev/null and b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon.png differ
diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@128w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@128w.png
new file mode 100644
index 00000000..c9479d72
Binary files /dev/null and b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@128w.png differ
diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@16w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@16w.png
new file mode 100644
index 00000000..f00e273e
Binary files /dev/null and b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@16w.png differ
diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@256w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@256w.png
new file mode 100644
index 00000000..0546b089
Binary files /dev/null and b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@256w.png differ
diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@32w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@32w.png
new file mode 100644
index 00000000..9f60ddf8
Binary files /dev/null and b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@32w.png differ
diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@512w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@512w.png
new file mode 100644
index 00000000..c00d18cf
Binary files /dev/null and b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@512w.png differ
diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@64w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@64w.png
new file mode 100644
index 00000000..625b2717
Binary files /dev/null and b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@64w.png differ
diff --git a/ExtensionService/ExtensionService.entitlements b/ExtensionService/ExtensionService.entitlements
index ae1430f1..5a41052f 100644
--- a/ExtensionService/ExtensionService.entitlements
+++ b/ExtensionService/ExtensionService.entitlements
@@ -6,8 +6,6 @@
$(TeamIdentifierPrefix)group.$(BUNDLE_IDENTIFIER_BASE)
- com.apple.security.cs.disable-library-validation
-
keychain-access-groups
$(AppIdentifierPrefix)$(BUNDLE_IDENTIFIER_BASE).Shared
diff --git a/ExtensionService/Info.plist b/ExtensionService/Info.plist
index 94c867be..9faed878 100644
--- a/ExtensionService/Info.plist
+++ b/ExtensionService/Info.plist
@@ -12,6 +12,11 @@
$(EXTENSION_BUNDLE_NAME)
HOST_APP_NAME
$(HOST_APP_NAME)
+ NSAppTransportSecurity
+
+ NSAllowsArbitraryLoads
+
+
TEAM_ID_PREFIX
$(TeamIdentifierPrefix)
XPCService
diff --git a/OverlayWindow/.gitignore b/OverlayWindow/.gitignore
new file mode 100644
index 00000000..0023a534
--- /dev/null
+++ b/OverlayWindow/.gitignore
@@ -0,0 +1,8 @@
+.DS_Store
+/.build
+/Packages
+xcuserdata/
+DerivedData/
+.swiftpm/configuration/registries.json
+.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
+.netrc
diff --git a/OverlayWindow/Package.swift b/OverlayWindow/Package.swift
new file mode 100644
index 00000000..b875c713
--- /dev/null
+++ b/OverlayWindow/Package.swift
@@ -0,0 +1,39 @@
+// swift-tools-version: 6.2
+// The swift-tools-version declares the minimum version of Swift required to build this package.
+
+import PackageDescription
+
+let package = Package(
+ name: "OverlayWindow",
+ platforms: [.macOS(.v13)],
+ products: [
+ .library(
+ name: "OverlayWindow",
+ targets: ["OverlayWindow"]
+ ),
+ ],
+ dependencies: [
+ .package(path: "../Tool"),
+ .package(url: "https://github.com/pointfreeco/swift-perception", from: "1.3.4"),
+ .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.4.0"),
+ ],
+ targets: [
+ .target(
+ name: "OverlayWindow",
+ dependencies: [
+ .product(name: "AppMonitoring", package: "Tool"),
+ .product(name: "Toast", package: "Tool"),
+ .product(name: "Preferences", package: "Tool"),
+ .product(name: "Logger", package: "Tool"),
+ .product(name: "DebounceFunction", package: "Tool"),
+ .product(name: "Perception", package: "swift-perception"),
+ .product(name: "Dependencies", package: "swift-dependencies"),
+ ]
+ ),
+ .testTarget(
+ name: "OverlayWindowTests",
+ dependencies: ["OverlayWindow", .product(name: "DebounceFunction", package: "Tool")]
+ ),
+ ]
+)
+
diff --git a/OverlayWindow/Sources/OverlayWindow/IDEWorkspaceWindowOverlayWindowController.swift b/OverlayWindow/Sources/OverlayWindow/IDEWorkspaceWindowOverlayWindowController.swift
new file mode 100644
index 00000000..48263e26
--- /dev/null
+++ b/OverlayWindow/Sources/OverlayWindow/IDEWorkspaceWindowOverlayWindowController.swift
@@ -0,0 +1,147 @@
+import AppKit
+import AXExtension
+import AXNotificationStream
+import DebounceFunction
+import Foundation
+import Perception
+import SwiftUI
+import XcodeInspector
+
+@MainActor
+public protocol IDEWorkspaceWindowOverlayWindowControllerContentProvider {
+ associatedtype Content: View
+ func createWindow() -> NSWindow?
+ func createContent() -> Content
+ func destroy()
+
+ init(windowInspector: WorkspaceXcodeWindowInspector, application: NSRunningApplication)
+}
+
+extension IDEWorkspaceWindowOverlayWindowControllerContentProvider {
+ var contentBody: AnyView {
+ AnyView(createContent())
+ }
+}
+
+@MainActor
+final class IDEWorkspaceWindowOverlayWindowController {
+ private var lastAccessDate: Date = .init()
+ let application: NSRunningApplication
+ let inspector: WorkspaceXcodeWindowInspector
+ let contentProviders: [any IDEWorkspaceWindowOverlayWindowControllerContentProvider]
+ let maskPanel: OverlayPanel
+ var windowElement: AXUIElement
+ private var axNotificationTask: Task?
+ let updateFrameThrottler = ThrottleRunner(duration: 0.2)
+
+ init(
+ inspector: WorkspaceXcodeWindowInspector,
+ application: NSRunningApplication,
+ contentProviderFactory: (
+ _ windowInspector: WorkspaceXcodeWindowInspector, _ application: NSRunningApplication
+ ) -> [any IDEWorkspaceWindowOverlayWindowControllerContentProvider]
+ ) {
+ self.inspector = inspector
+ self.application = application
+ let contentProviders = contentProviderFactory(inspector, application)
+ self.contentProviders = contentProviders
+ windowElement = inspector.uiElement
+
+ let panel = OverlayPanel(
+ contentRect: .init(x: 0, y: 0, width: 200, height: 200)
+ ) {
+ ZStack {
+ ForEach(0..(
+ contentRect: NSRect,
+ @ViewBuilder content: @escaping () -> Content
+ ) {
+ super.init(
+ contentRect: contentRect,
+ styleMask: [
+ .borderless,
+ .nonactivatingPanel,
+ .fullSizeContentView,
+ ],
+ backing: .buffered,
+ defer: false
+ )
+
+ isReleasedWhenClosed = false
+ menu = nil
+ isOpaque = true
+ backgroundColor = .clear
+ hasShadow = false
+ alphaValue = 1.0
+ collectionBehavior = [.fullScreenAuxiliary]
+ isFloatingPanel = true
+ titleVisibility = .hidden
+ titlebarAppearsTransparent = true
+ animationBehavior = .utilityWindow
+
+ standardWindowButton(.closeButton)?.isHidden = true
+ standardWindowButton(.miniaturizeButton)?.isHidden = true
+ standardWindowButton(.zoomButton)?.isHidden = true
+
+ contentView = NSHostingView(
+ rootView: ContentWrapper(panelState: panelState) { content() }
+ )
+ }
+
+ override public var canBecomeKey: Bool {
+ return _canBecomeKey
+ }
+
+ override public var canBecomeMain: Bool {
+ return false
+ }
+
+ override public func setIsVisible(_ visible: Bool) {
+ _canBecomeKey = false
+ defer { _canBecomeKey = true }
+ super.setIsVisible(visible)
+ }
+
+ public func moveToActiveSpace() {
+ collectionBehavior = [.fullScreenAuxiliary, .moveToActiveSpace]
+ Task { @MainActor in
+ try await Task.sleep(nanoseconds: 50_000_000)
+ self.collectionBehavior = [.fullScreenAuxiliary]
+ }
+ }
+
+ func setTopLeftCoordinateFrame(_ frame: CGRect, display: Bool) {
+ let zeroScreen = NSScreen.screens.first { $0.frame.origin == .zero }
+ ?? NSScreen.primaryScreen ?? NSScreen.main
+ let panelFrame = Self.convertAXRectToNSPanelFrame(
+ axRect: frame,
+ forPrimaryScreen: zeroScreen
+ )
+ panelState.windowFrame = frame
+ panelState.windowFrameNSCoordinate = panelFrame
+ setFrame(panelFrame, display: display)
+ }
+
+ static func convertAXRectToNSPanelFrame(
+ axRect: CGRect,
+ forPrimaryScreen screen: NSScreen?
+ ) -> CGRect {
+ guard let screen = screen else { return .zero }
+ let screenFrame = screen.frame
+ let flippedY = screenFrame.origin.y + screenFrame.size
+ .height - (axRect.origin.y + axRect.size.height)
+ return CGRect(
+ x: axRect.origin.x,
+ y: flippedY,
+ width: axRect.size.width,
+ height: axRect.size.height
+ )
+ }
+
+ struct ContentWrapper: View {
+ let panelState: PanelState
+ @ViewBuilder let content: () -> Content
+ @AppStorage(\.debugOverlayPanel) var debugOverlayPanel
+
+ var body: some View {
+ WithPerceptionTracking {
+ ZStack {
+ Rectangle().fill(.green.opacity(debugOverlayPanel ? 0.1 : 0))
+ .allowsHitTesting(false)
+ content()
+ .environment(\.overlayFrame, panelState.windowFrame)
+ .environment(\.overlayDebug, debugOverlayPanel)
+ }
+ }
+ }
+ }
+}
+
+func overlayLevel(_ addition: Int) -> NSWindow.Level {
+ let minimumWidgetLevel: Int
+ #if DEBUG
+ minimumWidgetLevel = NSWindow.Level.floating.rawValue + 1
+ #else
+ minimumWidgetLevel = NSWindow.Level.floating.rawValue
+ #endif
+ return .init(minimumWidgetLevel + addition)
+}
+
+public extension CGRect {
+ func flipped(relativeTo reference: CGRect) -> CGRect {
+ let flippedOrigin = CGPoint(
+ x: origin.x,
+ y: reference.height - origin.y - height
+ )
+ return CGRect(origin: flippedOrigin, size: size)
+ }
+
+ func relative(to reference: CGRect) -> CGRect {
+ let relativeOrigin = CGPoint(
+ x: origin.x - reference.origin.x,
+ y: origin.y - reference.origin.y
+ )
+ return CGRect(origin: relativeOrigin, size: size)
+ }
+}
+
+public extension NSScreen {
+ var isPrimary: Bool {
+ let id = deviceDescription[.init("NSScreenNumber")] as? CGDirectDisplayID
+ return id == CGMainDisplayID()
+ }
+
+ static var primaryScreen: NSScreen? {
+ NSScreen.screens.first {
+ let id = $0.deviceDescription[.init("NSScreenNumber")] as? CGDirectDisplayID
+ return id == CGMainDisplayID()
+ }
+ }
+}
+
diff --git a/OverlayWindow/Sources/OverlayWindow/OverlayWindowController.swift b/OverlayWindow/Sources/OverlayWindow/OverlayWindowController.swift
new file mode 100644
index 00000000..f4dbaa73
--- /dev/null
+++ b/OverlayWindow/Sources/OverlayWindow/OverlayWindowController.swift
@@ -0,0 +1,206 @@
+import AppKit
+import DebounceFunction
+import Foundation
+import Perception
+import XcodeInspector
+
+@MainActor
+public final class OverlayWindowController {
+ public typealias IDEWorkspaceWindowOverlayWindowControllerContentProviderFactory =
+ @MainActor @Sendable (
+ _ windowInspector: WorkspaceXcodeWindowInspector,
+ _ application: NSRunningApplication
+ ) -> any IDEWorkspaceWindowOverlayWindowControllerContentProvider
+
+ static var ideWindowOverlayWindowControllerContentProviderFactories:
+ [IDEWorkspaceWindowOverlayWindowControllerContentProviderFactory] = []
+
+ var ideWindowOverlayWindowControllers =
+ [ObjectIdentifier: IDEWorkspaceWindowOverlayWindowController]()
+ var updateWindowStateTask: Task?
+
+ let windowUpdateThrottler = ThrottleRunner(duration: 0.2)
+
+ lazy var fullscreenDetector = {
+ let it = NSWindow(
+ contentRect: .zero,
+ styleMask: .borderless,
+ backing: .buffered,
+ defer: false
+ )
+ it.isReleasedWhenClosed = false
+ it.isOpaque = false
+ it.backgroundColor = .clear
+ it.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient]
+ it.hasShadow = false
+ it.setIsVisible(false)
+ return it
+ }()
+
+ public init() {}
+
+ public func start() {
+ observeEvents()
+ _ = fullscreenDetector
+ }
+
+ public nonisolated static func registerIDEWorkspaceWindowOverlayWindowControllerContentProviderFactory(
+ _ factory: @escaping IDEWorkspaceWindowOverlayWindowControllerContentProviderFactory
+ ) {
+ Task { @MainActor in
+ ideWindowOverlayWindowControllerContentProviderFactories.append(factory)
+ }
+ }
+}
+
+extension OverlayWindowController {
+ func observeEvents() {
+ observeWindowChange()
+
+ updateWindowStateTask = Task { [weak self] in
+ if let self { await handleSpaceChange() }
+
+ await withThrowingTaskGroup(of: Void.self) { [weak self] group in
+ // active space did change
+ _ = group.addTaskUnlessCancelled { [weak self] in
+ let sequence = NSWorkspace.shared.notificationCenter
+ .notifications(named: NSWorkspace.activeSpaceDidChangeNotification)
+ for await _ in sequence {
+ guard let self else { return }
+ try Task.checkCancellation()
+ await handleSpaceChange()
+ }
+ }
+ }
+ }
+ }
+}
+
+private extension OverlayWindowController {
+ func observeWindowChange() {
+ if ideWindowOverlayWindowControllers.isEmpty {
+ if let app = XcodeInspector.shared.activeXcode,
+ let windowInspector = XcodeInspector.shared
+ .focusedWindow as? WorkspaceXcodeWindowInspector
+ {
+ createNewIDEOverlayWindowController(
+ inspector: windowInspector,
+ application: app.runningApplication
+ )
+ }
+ }
+
+ withPerceptionTracking {
+ _ = XcodeInspector.shared.focusedWindow
+ _ = XcodeInspector.shared.activeXcode
+ _ = XcodeInspector.shared.activeApplication
+ } onChange: { [weak self] in
+ guard let self else { return }
+ Task { @MainActor in
+ defer { self.observeWindowChange() }
+ await self.windowUpdateThrottler.throttle { [weak self] in
+ await self?.handleOverlayStatusChange()
+ }
+ }
+ }
+ }
+
+ func createNewIDEOverlayWindowController(
+ inspector: WorkspaceXcodeWindowInspector,
+ application: NSRunningApplication
+ ) {
+ let id = ObjectIdentifier(inspector)
+ let newController = IDEWorkspaceWindowOverlayWindowController(
+ inspector: inspector,
+ application: application,
+ contentProviderFactory: {
+ windowInspector, application in
+ OverlayWindowController.ideWindowOverlayWindowControllerContentProviderFactories
+ .map { $0(windowInspector, application) }
+ }
+ )
+ newController.access()
+ ideWindowOverlayWindowControllers[id] = newController
+ }
+
+ func removeIDEOverlayWindowController(for id: ObjectIdentifier) {
+ if let controller = ideWindowOverlayWindowControllers[id] {
+ controller.destroy()
+ }
+ ideWindowOverlayWindowControllers[id] = nil
+ }
+
+ func handleSpaceChange() async {
+ let windowInspector = XcodeInspector.shared.focusedWindow
+ guard let activeWindowController = {
+ if let windowInspector = windowInspector as? WorkspaceXcodeWindowInspector {
+ let id = ObjectIdentifier(windowInspector)
+ return ideWindowOverlayWindowControllers[id]
+ } else {
+ return nil
+ }
+ }() else { return }
+
+ let activeXcode = XcodeInspector.shared.activeXcode
+ let xcode = activeXcode?.appElement
+ let isXcodeActive = xcode?.isFrontmost ?? false
+ if isXcodeActive {
+ activeWindowController.maskPanel.moveToActiveSpace()
+ }
+
+ if fullscreenDetector.isOnActiveSpace, xcode?.focusedWindow != nil {
+ activeWindowController.maskPanel.orderFrontRegardless()
+ }
+ }
+
+ func handleOverlayStatusChange() {
+ guard XcodeInspector.shared.activeApplication?.isXcode ?? false else {
+ var closedControllers: [ObjectIdentifier] = []
+ for (id, controller) in ideWindowOverlayWindowControllers {
+ if controller.isWindowClosed {
+ controller.dim()
+ closedControllers.append(id)
+ } else {
+ controller.dim()
+ }
+ }
+ for id in closedControllers {
+ removeIDEOverlayWindowController(for: id)
+ }
+ return
+ }
+
+ guard let app = XcodeInspector.shared.activeXcode else {
+ for (_, controller) in ideWindowOverlayWindowControllers {
+ controller.hide()
+ }
+ return
+ }
+
+ let windowInspector = XcodeInspector.shared.focusedWindow
+ if let ideWindowInspector = windowInspector as? WorkspaceXcodeWindowInspector {
+ let objectID = ObjectIdentifier(ideWindowInspector)
+ // Workspace window is active
+ // Hide all controllers first
+ for (id, controller) in ideWindowOverlayWindowControllers {
+ if id != objectID {
+ controller.hide()
+ }
+ }
+ if let controller = ideWindowOverlayWindowControllers[objectID] {
+ controller.access()
+ } else {
+ createNewIDEOverlayWindowController(
+ inspector: ideWindowInspector,
+ application: app.runningApplication
+ )
+ }
+ } else {
+ // Not a workspace window, dim all controllers
+ for (_, controller) in ideWindowOverlayWindowControllers {
+ controller.dim()
+ }
+ }
+ }
+}
+
diff --git a/OverlayWindow/Tests/OverlayWindowTests/WindowTests.swift b/OverlayWindow/Tests/OverlayWindowTests/WindowTests.swift
new file mode 100644
index 00000000..98b0a5bf
--- /dev/null
+++ b/OverlayWindow/Tests/OverlayWindowTests/WindowTests.swift
@@ -0,0 +1,5 @@
+import Testing
+
+@Test func example() async throws {
+ // Write your test here and use APIs like `#expect(...)` to check expected conditions.
+}
diff --git a/README.md b/README.md
index 312e65ff..c4066a45 100644
--- a/README.md
+++ b/README.md
@@ -15,23 +15,38 @@ Copilot for Xcode is an Xcode Source Editor Extension that provides GitHub Copil
## Table of Contents
-- [Prerequisites](#prerequisites)
-- [Permissions Required](#permissions-required)
-- [Installation and Setup](#installation-and-setup)
- - [Install](#install)
- - [Enable the Extension](#enable-the-extension)
- - [Granting Permissions to the App](#granting-permissions-to-the-app)
- - [Setting Up Key Bindings](#setting-up-key-bindings)
- - [Setting Up Suggestion Feature](#setting-up-suggestion-feature)
- - [Setting Up GitHub Copilot](#setting-up-github-copilot)
- - [Setting Up Codeium](#setting-up-codeium)
- - [Using Locally Run LLMs](#using-locally-run-llms)
- - [Setting Up Chat Feature](#setting-up-chat-feature)
- - [Managing `CopilotForXcodeExtensionService.app`](#managing-copilotforxcodeextensionserviceapp)
-- [Update](#update)
-- [Feature](#feature)
-- [Limitations](#limitations)
-- [License](#license)
+- [Copilot for Xcode ](#copilot-for-xcode-)
+ - [Features](#features)
+ - [Table of Contents](#table-of-contents)
+ - [Prerequisites](#prerequisites)
+ - [Permissions Required](#permissions-required)
+ - [Installation and Setup](#installation-and-setup)
+ - [Install](#install)
+ - [Enable the Extension](#enable-the-extension)
+ - [macOS 15](#macos-15)
+ - [MacOS 14](#macos-14)
+ - [Older Versions](#older-versions)
+ - [Granting Permissions to the App](#granting-permissions-to-the-app)
+ - [Setting Up Key Bindings](#setting-up-key-bindings)
+ - [Setting Up Global Hotkeys](#setting-up-global-hotkeys)
+ - [Setting Up Suggestion Feature](#setting-up-suggestion-feature)
+ - [Setting Up GitHub Copilot](#setting-up-github-copilot)
+ - [Setting Up Codeium](#setting-up-codeium)
+ - [Setting Up Chat Feature](#setting-up-chat-feature)
+ - [Managing `CopilotForXcodeExtensionService.app`](#managing-copilotforxcodeextensionserviceapp)
+ - [Update](#update)
+ - [Feature](#feature)
+ - [Suggestion](#suggestion)
+ - [Commands](#commands)
+ - [Chat](#chat)
+ - [Commands](#commands-1)
+ - [Keyboard Shortcuts](#keyboard-shortcuts)
+ - [Chat Commands](#chat-commands)
+ - [Prompt to Code](#prompt-to-code)
+ - [Commands](#commands-2)
+ - [Custom Commands](#custom-commands)
+ - [Limitations](#limitations)
+ - [License](#license)
For development instruction, check [Development.md](DEVELOPMENT.md).
@@ -90,8 +105,13 @@ Open the app, the app will create a launch agent to setup a background running S
Enable the extension in `System Settings.app`.
+#### macOS 15
+From the Apple menu located in the top-left corner of your screen click `System Settings`. Navigate to `General` then `Login Items & Extensions`. Click `Xcode Source Editor` and tick `Copilot for Xcode`.
+
+#### MacOS 14
From the Apple menu located in the top-left corner of your screen click `System Settings`. Navigate to `Privacy & Security` then toward the bottom click `Extensions`. Click `Xcode Source Editor` and tick `Copilot`.
+#### Older Versions
If you are using macOS Monterey, enter the `Extensions` menu in `System Preferences.app` with its dedicated icon.
### Granting Permissions to the App
@@ -195,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.
@@ -231,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
@@ -242,24 +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 Scope
-
-The chat panel allows for chat scope to temporarily control the context of the conversation for the latest message. To use a scope, simply prefix the message with `@scope`.
-
-`@code` is on by default, if `Use @code scope by default in chat context.` is on. Otherwise, `@file` will be on by default.
-
-To use scopes, you can prefix a message with `@code`.
-
-You can use shorthand to represent a scope, such as `@c`, and enable multiple scopes with `@c+web`.
+#### Chat Commands
-#### Chat Plugins
-
-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
@@ -273,13 +283,10 @@ 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. |
-| `/search` | Search on Bing and summarize the results. You have to setup the Bing Search API in the host app before using it. |
| `/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. |
-| `/shortcutInput(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 send to the bot as a user message. |
### Prompt to Code
@@ -295,22 +302,14 @@ This feature is recommended when you need to update a specific piece of code. So
- Polishing and correcting grammar and spelling errors in the documentation.
- Translating a localizable strings file.
-#### Modification Scope
-
-The chat panel allows for chat scope to temporarily control the context of the conversation for the latest message. To use a scope, simply prefix the message with `@scope`.
-
-To use scopes, you can prefix a message with `@sense`.
-
-You can use shorthand to represent a scope, such as `@sense`, and enable multiple scopes with `@c+web`.
-
#### 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/Screenshot.png b/Screenshot.png
index 9b2bda3a..b4ad1f43 100644
Binary files a/Screenshot.png and b/Screenshot.png differ
diff --git a/TestPlan.xctestplan b/TestPlan.xctestplan
index 8806c7f2..c9ebe525 100644
--- a/TestPlan.xctestplan
+++ b/TestPlan.xctestplan
@@ -24,86 +24,93 @@
"testTargets" : [
{
"target" : {
- "containerPath" : "container:Core",
- "identifier" : "ServiceTests",
- "name" : "ServiceTests"
+ "containerPath" : "container:Tool",
+ "identifier" : "SharedUIComponentsTests",
+ "name" : "SharedUIComponentsTests"
}
},
{
"target" : {
- "containerPath" : "container:Core",
- "identifier" : "SuggestionWidgetTests",
- "name" : "SuggestionWidgetTests"
+ "containerPath" : "container:Tool",
+ "identifier" : "ActiveDocumentChatContextCollectorTests",
+ "name" : "ActiveDocumentChatContextCollectorTests"
+ }
+ },
+ {
+ "target" : {
+ "containerPath" : "container:Tool",
+ "identifier" : "CodeDiffTests",
+ "name" : "CodeDiffTests"
}
},
{
"target" : {
"containerPath" : "container:Core",
- "identifier" : "PromptToCodeServiceTests",
- "name" : "PromptToCodeServiceTests"
+ "identifier" : "ServiceUpdateMigrationTests",
+ "name" : "ServiceUpdateMigrationTests"
}
},
{
"target" : {
"containerPath" : "container:Tool",
- "identifier" : "LangChainTests",
- "name" : "LangChainTests"
+ "identifier" : "ASTParserTests",
+ "name" : "ASTParserTests"
}
},
{
"target" : {
"containerPath" : "container:Tool",
- "identifier" : "OpenAIServiceTests",
- "name" : "OpenAIServiceTests"
+ "identifier" : "SuggestionProviderTests",
+ "name" : "SuggestionProviderTests"
}
},
{
"target" : {
"containerPath" : "container:Core",
- "identifier" : "ChatServiceTests",
- "name" : "ChatServiceTests"
+ "identifier" : "PromptToCodeServiceTests",
+ "name" : "PromptToCodeServiceTests"
}
},
{
"target" : {
"containerPath" : "container:Tool",
- "identifier" : "TokenEncoderTests",
- "name" : "TokenEncoderTests"
+ "identifier" : "KeychainTests",
+ "name" : "KeychainTests"
}
},
{
"target" : {
"containerPath" : "container:Tool",
- "identifier" : "SharedUIComponentsTests",
- "name" : "SharedUIComponentsTests"
+ "identifier" : "JoinJSONTests",
+ "name" : "JoinJSONTests"
}
},
{
"target" : {
- "containerPath" : "container:Tool",
- "identifier" : "ASTParserTests",
- "name" : "ASTParserTests"
+ "containerPath" : "container:Core",
+ "identifier" : "ServiceTests",
+ "name" : "ServiceTests"
}
},
{
"target" : {
- "containerPath" : "container:Core",
- "identifier" : "ServiceUpdateMigrationTests",
- "name" : "ServiceUpdateMigrationTests"
+ "containerPath" : "container:OverlayWindow",
+ "identifier" : "OverlayWindowTests",
+ "name" : "OverlayWindowTests"
}
},
{
"target" : {
"containerPath" : "container:Tool",
- "identifier" : "KeychainTests",
- "name" : "KeychainTests"
+ "identifier" : "SuggestionBasicTests",
+ "name" : "SuggestionBasicTests"
}
},
{
"target" : {
"containerPath" : "container:Tool",
- "identifier" : "ActiveDocumentChatContextCollectorTests",
- "name" : "ActiveDocumentChatContextCollectorTests"
+ "identifier" : "LangChainTests",
+ "name" : "LangChainTests"
}
},
{
@@ -116,36 +123,50 @@
{
"target" : {
"containerPath" : "container:Tool",
- "identifier" : "FocusedCodeFinderTests",
- "name" : "FocusedCodeFinderTests"
+ "identifier" : "OpenAIServiceTests",
+ "name" : "OpenAIServiceTests"
}
},
{
"target" : {
- "containerPath" : "container:Tool",
- "identifier" : "XcodeInspectorTests",
- "name" : "XcodeInspectorTests"
+ "containerPath" : "container:Core",
+ "identifier" : "SuggestionWidgetTests",
+ "name" : "SuggestionWidgetTests"
+ }
+ },
+ {
+ "target" : {
+ "containerPath" : "container:Core",
+ "identifier" : "KeyBindingManagerTests",
+ "name" : "KeyBindingManagerTests"
}
},
{
"target" : {
"containerPath" : "container:Tool",
- "identifier" : "SuggestionProviderTests",
- "name" : "SuggestionProviderTests"
+ "identifier" : "FocusedCodeFinderTests",
+ "name" : "FocusedCodeFinderTests"
}
},
{
"target" : {
- "containerPath" : "container:Core",
- "identifier" : "KeyBindingManagerTests",
- "name" : "KeyBindingManagerTests"
+ "containerPath" : "container:Tool",
+ "identifier" : "WebSearchServiceTests",
+ "name" : "WebSearchServiceTests"
}
},
{
"target" : {
"containerPath" : "container:Tool",
- "identifier" : "SuggestionBasicTests",
- "name" : "SuggestionBasicTests"
+ "identifier" : "XcodeInspectorTests",
+ "name" : "XcodeInspectorTests"
+ }
+ },
+ {
+ "target" : {
+ "containerPath" : "container:Core",
+ "identifier" : "ChatServiceTests",
+ "name" : "ChatServiceTests"
}
},
{
@@ -158,8 +179,8 @@
{
"target" : {
"containerPath" : "container:Tool",
- "identifier" : "CodeDiffTests",
- "name" : "CodeDiffTests"
+ "identifier" : "TokenEncoderTests",
+ "name" : "TokenEncoderTests"
}
}
],
diff --git a/Tool/Package.swift b/Tool/Package.swift
index 06730e8b..f303e44c 100644
--- a/Tool/Package.swift
+++ b/Tool/Package.swift
@@ -10,7 +10,7 @@ let package = Package(
.library(name: "XPCShared", targets: ["XPCShared"]),
.library(name: "Terminal", targets: ["Terminal"]),
.library(name: "LangChain", targets: ["LangChain"]),
- .library(name: "ExternalServices", targets: ["BingSearchService"]),
+ .library(name: "ExternalServices", targets: ["WebSearchService"]),
.library(name: "Preferences", targets: ["Preferences", "Configs"]),
.library(name: "Logger", targets: ["Logger"]),
.library(name: "OpenAIService", targets: ["OpenAIService"]),
@@ -21,7 +21,8 @@ let package = Package(
targets: ["ChatContextCollector", "ActiveDocumentChatContextCollector"]
),
.library(name: "SuggestionBasic", targets: ["SuggestionBasic", "SuggestionInjector"]),
- .library(name: "PromptToCode", targets: ["PromptToCodeBasic", "PromptToCodeCustomization"]),
+ .library(name: "PromptToCode", targets: ["ModificationBasic", "PromptToCodeCustomization"]),
+ .library(name: "Chat", targets: ["ChatBasic"]),
.library(name: "ASTParser", targets: ["ASTParser"]),
.library(name: "FocusedCodeFinder", targets: ["FocusedCodeFinder"]),
.library(name: "Toast", targets: ["Toast"]),
@@ -50,6 +51,13 @@ let package = Package(
.library(name: "CustomAsyncAlgorithms", targets: ["CustomAsyncAlgorithms"]),
.library(name: "CommandHandler", targets: ["CommandHandler"]),
.library(name: "CodeDiff", targets: ["CodeDiff"]),
+ .library(name: "BuiltinExtension", targets: ["BuiltinExtension"]),
+ .library(name: "WebSearchService", targets: ["WebSearchService"]),
+ .library(name: "WebScrapper", targets: ["WebScrapper"]),
+ .library(
+ name: "CustomCommandTemplateProcessor",
+ targets: ["CustomCommandTemplateProcessor"]
+ ),
],
dependencies: [
// A fork of https://github.com/aespinilla/Tiktoken to allow loading from local files.
@@ -65,16 +73,19 @@ let package = Package(
.package(url: "https://github.com/intitni/Highlightr", branch: "master"),
.package(
url: "https://github.com/pointfreeco/swift-composable-architecture",
- exact: "1.10.4"
+ exact: "1.16.1"
),
- .package(url: "https://github.com/apple/swift-syntax.git", exact: "509.0.2"),
+ .package(url: "https://github.com/apple/swift-syntax.git", from: "600.0.0"),
.package(url: "https://github.com/GottaGetSwifty/CodableWrappers", from: "2.0.7"),
// A fork of https://github.com/google/generative-ai-swift to support setting base url.
.package(
url: "https://github.com/intitni/generative-ai-swift",
branch: "support-setting-base-url"
),
- .package(url: "https://github.com/intitni/CopilotForXcodeKit", from: "0.7.1"),
+ .package(
+ url: "https://github.com/intitni/CopilotForXcodeKit",
+ branch: "feature/custom-chat-tab"
+ ),
// TreeSitter
.package(url: "https://github.com/intitni/SwiftTreeSitter.git", branch: "main"),
@@ -97,6 +108,9 @@ let package = Package(
.target(name: "ObjectiveCExceptionHandling"),
+ .target(name: "JoinJSON"),
+ .testTarget(name: "JoinJSONTests", dependencies: ["JoinJSON"]),
+
.target(name: "CodeDiff", dependencies: ["SuggestionBasic"]),
.testTarget(name: "CodeDiffTests", dependencies: ["CodeDiff"]),
@@ -125,6 +139,14 @@ let package = Package(
)]
),
+ .target(
+ name: "CustomCommandTemplateProcessor",
+ dependencies: [
+ "XcodeInspector",
+ "SuggestionBasic",
+ ]
+ ),
+
.target(name: "DebounceFunction"),
.target(
@@ -198,9 +220,10 @@ let package = Package(
),
.target(
- name: "PromptToCodeBasic",
+ name: "ModificationBasic",
dependencies: [
"SuggestionBasic",
+ "ChatBasic",
.product(name: "CodableWrappers", package: "CodableWrappers"),
.product(
name: "ComposableArchitecture",
@@ -208,12 +231,14 @@ let package = Package(
),
]
),
+ .testTarget(name: "ModificationBasicTests", dependencies: ["ModificationBasic"]),
.target(
name: "PromptToCodeCustomization",
dependencies: [
- "PromptToCodeBasic",
+ "ModificationBasic",
"SuggestionBasic",
+ "ChatBasic",
.product(
name: "ComposableArchitecture",
package: "swift-composable-architecture"
@@ -221,7 +246,10 @@ let package = Package(
]
),
- .target(name: "AXExtension"),
+ .target(
+ name: "AXExtension",
+ dependencies: ["Logger"]
+ ),
.target(
name: "AXNotificationStream",
@@ -340,6 +368,8 @@ let package = Package(
dependencies: [
"XcodeInspector",
"Preferences",
+ "ChatBasic",
+ "ModificationBasic",
.product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
]
),
@@ -359,7 +389,12 @@ let package = Package(
]
),
- .target(name: "BingSearchService"),
+ .target(name: "WebScrapper", dependencies: [
+ .product(name: "SwiftSoup", package: "SwiftSoup"),
+ ]),
+
+ .target(name: "WebSearchService", dependencies: ["Preferences", "WebScrapper", "Keychain"]),
+ .testTarget(name: "WebSearchServiceTests", dependencies: ["WebSearchService"]),
.target(name: "SuggestionProvider", dependencies: [
"SuggestionBasic",
@@ -436,6 +471,8 @@ let package = Package(
"Keychain",
"BuiltinExtension",
"ChatBasic",
+ "GitHubCopilotService",
+ "JoinJSON",
.product(name: "JSONRPC", package: "JSONRPC"),
.product(name: "AsyncAlgorithms", package: "swift-async-algorithms"),
.product(name: "GoogleGenerativeAI", package: "generative-ai-swift"),
@@ -461,10 +498,16 @@ let package = Package(
.target(
name: "ChatTab",
- dependencies: [.product(
- name: "ComposableArchitecture",
- package: "swift-composable-architecture"
- )]
+ dependencies: [
+ "Preferences",
+ "Configs",
+ "AIModel",
+ .product(name: "CodableWrappers", package: "CodableWrappers"),
+ .product(
+ name: "ComposableArchitecture",
+ package: "swift-composable-architecture"
+ ),
+ ]
),
// MARK: - Chat Context Collector
diff --git a/Tool/Sources/AIModel/ChatModel.swift b/Tool/Sources/AIModel/ChatModel.swift
index e0f0ef5c..145d0298 100644
--- a/Tool/Sources/AIModel/ChatModel.swift
+++ b/Tool/Sources/AIModel/ChatModel.swift
@@ -23,6 +23,7 @@ public struct ChatModel: Codable, Equatable, Identifiable {
case googleAI
case ollama
case claude
+ case gitHubCopilot
}
public struct Info: Codable, Equatable {
@@ -46,16 +47,26 @@ public struct ChatModel: Codable, Equatable, Identifiable {
self.projectID = projectID
}
}
-
+
public struct OpenAICompatibleInfo: Codable, Equatable {
@FallbackDecoding
public var enforceMessageOrder: Bool
+ @FallbackDecoding
+ public var supportsMultipartMessageContent: Bool
+ @FallbackDecoding
+ public var requiresBeginWithUserMessage: Bool
- public init(enforceMessageOrder: Bool = false) {
+ public init(
+ enforceMessageOrder: Bool = false,
+ supportsMultipartMessageContent: Bool = true,
+ requiresBeginWithUserMessage: Bool = false
+ ) {
self.enforceMessageOrder = enforceMessageOrder
+ self.supportsMultipartMessageContent = supportsMultipartMessageContent
+ self.requiresBeginWithUserMessage = requiresBeginWithUserMessage
}
}
-
+
public struct GoogleGenerativeAIInfo: Codable, Equatable {
@FallbackDecoding
public var apiVersion: String
@@ -65,6 +76,33 @@ public struct ChatModel: Codable, Equatable, Identifiable {
}
}
+ public struct CustomHeaderInfo: Codable, Equatable {
+ public struct HeaderField: Codable, Equatable {
+ public var key: String
+ public var value: String
+
+ public init(key: String, value: String) {
+ self.key = key
+ self.value = value
+ }
+ }
+
+ @FallbackDecoding
+ public var headers: [HeaderField]
+
+ public init(headers: [HeaderField] = []) {
+ self.headers = headers
+ }
+ }
+
+ public struct CustomBodyInfo: Codable, Equatable {
+ public var jsonBody: String
+
+ public init(jsonBody: String = "") {
+ self.jsonBody = jsonBody
+ }
+ }
+
@FallbackDecoding
public var apiKeyName: String
@FallbackDecoding
@@ -75,6 +113,10 @@ public struct ChatModel: Codable, Equatable, Identifiable {
public var maxTokens: Int
@FallbackDecoding
public var supportsFunctionCalling: Bool
+ @FallbackDecoding
+ public var supportsImage: Bool
+ @FallbackDecoding
+ public var supportsAudio: Bool
@FallbackDecoding
public var modelName: String
@@ -86,6 +128,10 @@ public struct ChatModel: Codable, Equatable, Identifiable {
public var googleGenerativeAIInfo: GoogleGenerativeAIInfo
@FallbackDecoding
public var openAICompatibleInfo: OpenAICompatibleInfo
+ @FallbackDecoding
+ public var customHeaderInfo: CustomHeaderInfo
+ @FallbackDecoding
+ public var customBodyInfo: CustomBodyInfo
public init(
apiKeyName: String = "",
@@ -93,22 +139,30 @@ public struct ChatModel: Codable, Equatable, Identifiable {
isFullURL: Bool = false,
maxTokens: Int = 4000,
supportsFunctionCalling: Bool = true,
+ supportsImage: Bool = false,
+ supportsAudio: Bool = false,
modelName: String = "",
openAIInfo: OpenAIInfo = OpenAIInfo(),
ollamaInfo: OllamaInfo = OllamaInfo(),
googleGenerativeAIInfo: GoogleGenerativeAIInfo = GoogleGenerativeAIInfo(),
- openAICompatibleInfo: OpenAICompatibleInfo = OpenAICompatibleInfo()
+ openAICompatibleInfo: OpenAICompatibleInfo = OpenAICompatibleInfo(),
+ customHeaderInfo: CustomHeaderInfo = CustomHeaderInfo(),
+ customBodyInfo: CustomBodyInfo = CustomBodyInfo()
) {
self.apiKeyName = apiKeyName
self.baseURL = baseURL
self.isFullURL = isFullURL
self.maxTokens = maxTokens
self.supportsFunctionCalling = supportsFunctionCalling
+ self.supportsImage = supportsImage
+ self.supportsAudio = supportsAudio
self.modelName = modelName
self.openAIInfo = openAIInfo
self.ollamaInfo = ollamaInfo
self.googleGenerativeAIInfo = googleGenerativeAIInfo
self.openAICompatibleInfo = openAICompatibleInfo
+ self.customHeaderInfo = customHeaderInfo
+ self.customBodyInfo = customBodyInfo
}
}
@@ -141,6 +195,8 @@ public struct ChatModel: Codable, Equatable, Identifiable {
let baseURL = info.baseURL
if baseURL.isEmpty { return "https://api.anthropic.com/v1/messages" }
return "\(baseURL)/v1/messages"
+ case .gitHubCopilot:
+ return "https://api.githubcopilot.com/chat/completions"
}
}
}
@@ -168,3 +224,15 @@ public struct EmptyChatModelGoogleGenerativeAIInfo: FallbackValueProvider {
public struct EmptyChatModelOpenAICompatibleInfo: FallbackValueProvider {
public static var defaultValue: ChatModel.Info.OpenAICompatibleInfo { .init() }
}
+
+public struct EmptyChatModelCustomHeaderInfo: FallbackValueProvider {
+ public static var defaultValue: ChatModel.Info.CustomHeaderInfo { .init() }
+}
+
+public struct EmptyChatModelCustomBodyInfo: FallbackValueProvider {
+ public static var defaultValue: ChatModel.Info.CustomBodyInfo { .init() }
+}
+
+public struct EmptyTrue: FallbackValueProvider {
+ public static var defaultValue: Bool { true }
+}
diff --git a/Tool/Sources/AIModel/EmbeddingModel.swift b/Tool/Sources/AIModel/EmbeddingModel.swift
index d86650fa..4e192dda 100644
--- a/Tool/Sources/AIModel/EmbeddingModel.swift
+++ b/Tool/Sources/AIModel/EmbeddingModel.swift
@@ -21,11 +21,13 @@ public struct EmbeddingModel: Codable, Equatable, Identifiable {
case azureOpenAI
case openAICompatible
case ollama
+ case gitHubCopilot
}
public struct Info: Codable, Equatable {
public typealias OllamaInfo = ChatModel.Info.OllamaInfo
public typealias OpenAIInfo = ChatModel.Info.OpenAIInfo
+ public typealias CustomHeaderInfo = ChatModel.Info.CustomHeaderInfo
@FallbackDecoding
public var apiKeyName: String
@@ -44,6 +46,8 @@ public struct EmbeddingModel: Codable, Equatable, Identifiable {
public var openAIInfo: OpenAIInfo
@FallbackDecoding
public var ollamaInfo: OllamaInfo
+ @FallbackDecoding
+ public var customHeaderInfo: CustomHeaderInfo
public init(
apiKeyName: String = "",
@@ -53,7 +57,8 @@ public struct EmbeddingModel: Codable, Equatable, Identifiable {
dimensions: Int = 1536,
modelName: String = "",
openAIInfo: OpenAIInfo = OpenAIInfo(),
- ollamaInfo: OllamaInfo = OllamaInfo()
+ ollamaInfo: OllamaInfo = OllamaInfo(),
+ customHeaderInfo: CustomHeaderInfo = CustomHeaderInfo()
) {
self.apiKeyName = apiKeyName
self.baseURL = baseURL
@@ -63,6 +68,7 @@ public struct EmbeddingModel: Codable, Equatable, Identifiable {
self.modelName = modelName
self.openAIInfo = openAIInfo
self.ollamaInfo = ollamaInfo
+ self.customHeaderInfo = customHeaderInfo
}
}
@@ -87,6 +93,8 @@ public struct EmbeddingModel: Codable, Equatable, Identifiable {
let baseURL = info.baseURL
if baseURL.isEmpty { return "http://localhost:11434/api/embeddings" }
return "\(baseURL)/api/embeddings"
+ case .gitHubCopilot:
+ return "https://api.githubcopilot.com/embeddings"
}
}
}
diff --git a/Tool/Sources/AXExtension/AXUIElement.swift b/Tool/Sources/AXExtension/AXUIElement.swift
index 008dec6e..e54bfaff 100644
--- a/Tool/Sources/AXExtension/AXUIElement.swift
+++ b/Tool/Sources/AXExtension/AXUIElement.swift
@@ -1,5 +1,6 @@
import AppKit
import Foundation
+import Logger
// MARK: - State
@@ -57,7 +58,9 @@ public extension AXUIElement {
}
var isSourceEditor: Bool {
- description == "Source Editor"
+ if !(description == "Source Editor" && role != kAXUnknownRole) { return false }
+ if let _ = firstParent(where: { $0.identifier == "editor context" }) { return true }
+ return false
}
var selectedTextRange: ClosedRange? {
@@ -81,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
@@ -135,8 +167,26 @@ 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 {
- (try? copyValue(key: kAXFrontmostAttribute)) ?? false
+ get {
+ (try? copyValue(key: kAXFrontmostAttribute)) ?? false
+ }
+ set {
+ AXUIElementSetAttributeValue(
+ self,
+ kAXFrontmostAttribute as CFString,
+ newValue as CFBoolean
+ )
+ }
}
var focusedWindow: AXUIElement? {
@@ -170,8 +220,15 @@ public extension AXUIElement {
func child(
identifier: String? = nil,
title: String? = nil,
- role: String? = nil
+ role: String? = nil,
+ depth: Int = 0
) -> AXUIElement? {
+ #if DEBUG
+ if depth >= 50 {
+ fatalError("AXUIElement.child: Exceeding recommended depth.")
+ }
+ #endif
+
for child in children {
let match = {
if let identifier, child.identifier != identifier { return false }
@@ -185,7 +242,8 @@ public extension AXUIElement {
if let target = child.child(
identifier: identifier,
title: title,
- role: role
+ role: role,
+ depth: depth + 1
) { return target }
}
return nil
@@ -201,13 +259,19 @@ public extension AXUIElement {
renamed: "traverse(_:)",
message: "Please make use ``AXUIElement\traverse(_:)`` instead."
)
- func children(where match: (AXUIElement) -> Bool) -> [AXUIElement] {
+ func children(depth: Int = 0, where match: (AXUIElement) -> Bool) -> [AXUIElement] {
+ #if DEBUG
+ if depth >= 50 {
+ fatalError("AXUIElement.children: Exceeding recommended depth.")
+ }
+ #endif
+
var all = [AXUIElement]()
for child in children {
if match(child) { all.append(child) }
}
for child in children {
- all.append(contentsOf: child.children(where: match))
+ all.append(contentsOf: child.children(depth: depth + 1, where: match))
}
return all
}
@@ -218,12 +282,25 @@ public extension AXUIElement {
return parent.firstParent(where: match)
}
- func firstChild(where match: (AXUIElement) -> Bool) -> AXUIElement? {
+ func firstChild(
+ depth: Int = 0,
+ maxDepth: Int = 50,
+ where match: (AXUIElement) -> Bool
+ ) -> AXUIElement? {
+ #if DEBUG
+ 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 }
}
for child in children {
- if let target = child.firstChild(where: match) {
+ if let target = child.firstChild(depth: depth + 1, where: match) {
return target
}
}
@@ -244,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
}
@@ -258,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
}
}
@@ -285,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)
+ }
}
}
@@ -320,5 +442,7 @@ public extension AXUIElement {
}
}
-extension AXError: Error {}
+extension AXError: @retroactive _BridgedNSError {}
+extension AXError: @retroactive _ObjectiveCBridgeableError {}
+extension AXError: @retroactive Error {}
diff --git a/Tool/Sources/AXExtension/AXUIElementPrivateAPI.swift b/Tool/Sources/AXExtension/AXUIElementPrivateAPI.swift
new file mode 100644
index 00000000..bd861a3f
--- /dev/null
+++ b/Tool/Sources/AXExtension/AXUIElementPrivateAPI.swift
@@ -0,0 +1,8 @@
+import AppKit
+
+/// AXError _AXUIElementGetWindow(AXUIElementRef element, uint32_t *identifier);
+@_silgen_name("_AXUIElementGetWindow") @discardableResult
+func AXUIElementGetWindow(
+ _ element: AXUIElement,
+ _ identifier: UnsafeMutablePointer
+) -> AXError
diff --git a/Tool/Sources/AXNotificationStream/AXNotificationStream.swift b/Tool/Sources/AXNotificationStream/AXNotificationStream.swift
index 89fca015..b361f8ae 100644
--- a/Tool/Sources/AXNotificationStream/AXNotificationStream.swift
+++ b/Tool/Sources/AXNotificationStream/AXNotificationStream.swift
@@ -133,9 +133,12 @@ public final class AXNotificationStream: AsyncSequence {
.error("AXObserver: Accessibility API disabled, will try again later")
retry -= 1
case .invalidUIElement:
+ // It's possible that the UI element is not ready yet.
+ //
+ // Especially when you retrieve an element right after macOS is
+ // awaken from sleep.
Logger.service
.error("AXObserver: Invalid UI element, notification name \(name)")
- pendingRegistrationNames.remove(name)
case .invalidUIElementObserver:
Logger.service.error("AXObserver: Invalid UI element observer")
pendingRegistrationNames.remove(name)
diff --git a/Tool/Sources/AppActivator/AppActivator.swift b/Tool/Sources/AppActivator/AppActivator.swift
index cb5ec194..2011360a 100644
--- a/Tool/Sources/AppActivator/AppActivator.swift
+++ b/Tool/Sources/AppActivator/AppActivator.swift
@@ -17,32 +17,48 @@ public extension NSWorkspace {
// Fallback solution
- let appleScript = """
- tell application "System Events"
- set frontmost of the first process whose unix id is \
- \(ProcessInfo.processInfo.processIdentifier) to true
- end tell
- """
- try await runAppleScript(appleScript)
+ let axApplication = AXUIElementCreateApplication(
+ ProcessInfo.processInfo.processIdentifier
+ )
+ activateAppElement(axApplication)
+//
+// let appleScript = """
+// tell application "System Events"
+// set frontmost of the first process whose unix id is \
+// \(ProcessInfo.processInfo.processIdentifier) to true
+// end tell
+// """
+// try await runAppleScript(appleScript)
}
}
static func activatePreviousActiveApp(delay: TimeInterval = 0.2) {
Task { @MainActor in
- guard let app = await XcodeInspector.shared.safe.previousActiveApplication
+ guard let app = XcodeInspector.shared.previousActiveApplication
else { return }
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
- _ = app.activate()
+ activateApp(app)
}
}
static func activatePreviousActiveXcode(delay: TimeInterval = 0.2) {
Task { @MainActor in
- guard let app = await XcodeInspector.shared.safe.latestActiveXcode else { return }
+ guard let app = XcodeInspector.shared.latestActiveXcode else { return }
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
- _ = app.activate()
+ activateApp(app)
}
}
+
+ static func activateApp(_ app: AppInstanceInspector) {
+ // we prefer `.activate()` because it only brings the active window to the front
+ if !app.activate() {
+ activateAppElement(app.appElement)
+ }
+ }
+
+ static func activateAppElement(_ appElement: AXUIElement) {
+ appElement.isFrontmost = true
+ }
}
struct ActivateThisAppDependencyKey: DependencyKey {
diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtension.swift b/Tool/Sources/BuiltinExtension/BuiltinExtension.swift
index 88a830e4..80cdaccb 100644
--- a/Tool/Sources/BuiltinExtension/BuiltinExtension.swift
+++ b/Tool/Sources/BuiltinExtension/BuiltinExtension.swift
@@ -5,13 +5,11 @@ import Foundation
import Preferences
public protocol BuiltinExtension: CopilotForXcodeExtensionCapability {
- /// An id that let the extension manager determine whether the extension is in use.
- var suggestionServiceId: BuiltInSuggestionFeatureProvider { get }
/// An identifier for the extension.
var extensionIdentifier: String { get }
/// All chat builders provided by this extension.
- var chatTabTypes: [any ChatTab.Type] { get }
+ var chatTabTypes: [any CustomChatTab] { get }
/// It's usually called when the app is about to quit,
/// you should clean up all the resources here.
@@ -21,7 +19,8 @@ public protocol BuiltinExtension: CopilotForXcodeExtensionCapability {
// MARK: - Default Implementation
public extension BuiltinExtension {
- var chatTabTypes: [any ChatTab.Type] { [] }
+ var suggestionServiceId: BuiltInSuggestionFeatureProvider? { nil }
+ var chatTabTypes: [any CustomChatTab] { [] }
}
// MAKR: - ChatService
@@ -41,7 +40,7 @@ public protocol BuiltinExtensionChatServiceType: ChatServiceType {
public struct RetrievedContent {
public var document: ChatMessage.Reference
public var priority: Int
-
+
public init(document: ChatMessage.Reference, priority: Int) {
self.document = document
self.priority = priority
@@ -62,3 +61,29 @@ public enum ChatServiceMemoryMutation: Codable {
case streamIntoMessage(id: String, role: Message.Role?, text: String?)
}
+public protocol CustomChatTab {
+ var name: String { get }
+ var isDefaultChatTabReplacement: Bool { get }
+ var canHandleOpenChatCommand: Bool { get }
+ func chatBuilders() -> [ChatTabBuilder]
+ func defaultChatBuilder() -> ChatTabBuilder
+ func restore(from data: Data) async throws -> any ChatTabBuilder
+}
+
+public struct TypedCustomChatTab: CustomChatTab {
+ public let type: ChatTab.Type
+
+ public init(of type: ChatTab.Type) {
+ self.type = type
+ }
+
+ public var name: String { type.name }
+ public var isDefaultChatTabReplacement: Bool { type.isDefaultChatTabReplacement }
+ public var canHandleOpenChatCommand: Bool { type.canHandleOpenChatCommand }
+ public func chatBuilders() -> [ChatTabBuilder] { type.chatBuilders() }
+ public func defaultChatBuilder() -> ChatTabBuilder { type.defaultChatBuilder() }
+ public func restore(from data: Data) async throws -> any ChatTabBuilder {
+ try await type.restore(from: data)
+ }
+}
+
diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtensionManager.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionManager.swift
index 1d67657e..86832df5 100644
--- a/Tool/Sources/BuiltinExtension/BuiltinExtensionManager.swift
+++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionManager.swift
@@ -7,14 +7,19 @@ public final class BuiltinExtensionManager {
public static let shared: BuiltinExtensionManager = .init()
public private(set) var extensions: [any BuiltinExtension] = []
- private var cancellable: Set = []
-
init() {
- XcodeInspector.shared.$activeApplication.sink { [weak self] app in
- if let app, app.isXcode || app.isExtensionService {
- self?.checkAppConfiguration()
+ Task { [weak self] in
+ let notifications = NotificationCenter.default
+ .notifications(named: .activeApplicationDidChange)
+ for await _ in notifications {
+ guard let self else { return }
+ if let app = await XcodeInspector.shared.activeApplication,
+ app.isXcode || app.isExtensionService
+ {
+ self.checkAppConfiguration()
+ }
}
- }.store(in: &cancellable)
+ }
}
public func setupExtensions(_ extensions: [any BuiltinExtension]) {
@@ -22,6 +27,11 @@ public final class BuiltinExtensionManager {
checkAppConfiguration()
}
+ public func addExtensions(_ extensions: [any BuiltinExtension]) {
+ self.extensions.append(contentsOf: extensions)
+ checkAppConfiguration()
+ }
+
public func terminate() {
for ext in extensions {
ext.terminate()
@@ -33,8 +43,17 @@ extension BuiltinExtensionManager {
func checkAppConfiguration() {
let suggestionFeatureProvider = UserDefaults.shared.value(for: \.suggestionFeatureProvider)
for ext in extensions {
- let isSuggestionFeatureInUse = suggestionFeatureProvider ==
- .builtIn(ext.suggestionServiceId)
+ let isSuggestionFeatureInUse = switch suggestionFeatureProvider {
+ case let .builtIn(provider):
+ switch provider {
+ case .gitHubCopilot:
+ ext.extensionIdentifier == "com.github.copilot"
+ case .codeium:
+ ext.extensionIdentifier == "com.codeium"
+ }
+ case let .extension(_, bundleIdentifier):
+ ext.extensionIdentifier == bundleIdentifier
+ }
let isChatFeatureInUse = false
ext.extensionUsageDidChange(.init(
isSuggestionServiceInUse: isSuggestionFeatureInUse,
diff --git a/Tool/Sources/ChatBasic/ChatAgent.swift b/Tool/Sources/ChatBasic/ChatAgent.swift
index 30be4caa..1b6b835d 100644
--- a/Tool/Sources/ChatBasic/ChatAgent.swift
+++ b/Tool/Sources/ChatBasic/ChatAgent.swift
@@ -3,36 +3,81 @@ import Foundation
public enum ChatAgentResponse {
public enum Content {
case text(String)
- case modification
}
-
+
+ public enum ActionResult {
+ case success(String)
+ case failure(String)
+ }
+
/// Post the status of the current message.
- case status(String)
- /// Update the text message to the current message.
- case content([Content])
+ case status([String])
+ /// Stream the content to the current message.
+ case content(Content)
/// Update the attachments of the current message.
case attachments([URL])
+ /// start a new action.
+ case startAction(id: String, task: String)
+ /// Finish the current action.
+ case finishAction(id: String, result: ActionResult)
/// Update the references of the current message.
case references([ChatMessage.Reference])
/// End the current message. The next contents will be sent as a new message.
case startNewMessage
+ /// Reasoning
+ case reasoning(String)
}
public struct ChatAgentRequest {
public var text: String
public var history: [ChatMessage]
- public var extraContext: String
+ public var references: [ChatMessage.Reference]
+ public var topics: [ChatMessage.Reference]
+ public var agentInstructions: String? = nil
- public init(text: String, history: [ChatMessage], extraContext: String) {
+ public init(
+ text: String,
+ history: [ChatMessage],
+ references: [ChatMessage.Reference],
+ topics: [ChatMessage.Reference],
+ agentInstructions: String? = nil
+ ) {
self.text = text
self.history = history
- self.extraContext = extraContext
+ self.references = references
+ self.topics = topics
+ self.agentInstructions = agentInstructions
}
}
public protocol ChatAgent {
typealias Response = ChatAgentResponse
typealias Request = ChatAgentRequest
+ /// Send a request to the agent.
func send(_ request: Request) async -> AsyncThrowingStream
}
+public extension AsyncThrowingStream {
+ func asTexts() async throws -> [String] {
+ var result = [String]()
+ var text = ""
+ for try await response in self {
+ switch response {
+ case let .content(.text(content)):
+ text += content
+ case .startNewMessage:
+ if !text.isEmpty {
+ result.append(text)
+ text = ""
+ }
+ default:
+ break
+ }
+ }
+ if !text.isEmpty {
+ result.append(text)
+ }
+ return result
+ }
+}
+
diff --git a/Tool/Sources/ChatBasic/ChatGPTFunction.swift b/Tool/Sources/ChatBasic/ChatGPTFunction.swift
index 0e2690da..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.
@@ -68,20 +95,10 @@ public extension ChatGPTFunction where Arguments == NoArguments {
public protocol ChatGPTArgumentsCollectingFunction: ChatGPTFunction where Result == String {}
public extension ChatGPTArgumentsCollectingFunction {
- @available(
- *,
- deprecated,
- message: "This function is only used to get a structured output from the bot."
- )
func prepare(reportProgress: @escaping ReportProgress = { _ in }) async {
assertionFailure("This function is only used to get a structured output from the bot.")
}
- @available(
- *,
- deprecated,
- message: "This function is only used to get a structured output from the bot."
- )
func call(
arguments: Arguments,
reportProgress: @escaping ReportProgress = { _ in }
@@ -90,11 +107,6 @@ public extension ChatGPTArgumentsCollectingFunction {
return ""
}
- @available(
- *,
- deprecated,
- message: "This function is only used to get a structured output from the bot."
- )
func call(
argumentsJsonString: String,
reportProgress: @escaping ReportProgress
@@ -104,7 +116,7 @@ public extension ChatGPTArgumentsCollectingFunction {
}
}
-public struct ChatGPTFunctionSchema: Codable, Equatable {
+public struct ChatGPTFunctionSchema: Codable, Equatable, Sendable {
public var name: String
public var description: String
public var parameters: JSONSchemaValue
diff --git a/Tool/Sources/ChatBasic/ChatMessage.swift b/Tool/Sources/ChatBasic/ChatMessage.swift
index a6df9be6..ab5f04a4 100644
--- a/Tool/Sources/ChatBasic/ChatMessage.swift
+++ b/Tool/Sources/ChatBasic/ChatMessage.swift
@@ -1,19 +1,21 @@
-import CodableWrappers
+@preconcurrency import CodableWrappers
import Foundation
/// A chat message that can be sent or received.
-public struct ChatMessage: Equatable, Codable {
+public struct ChatMessage: Equatable, Codable, Sendable {
public typealias ID = String
/// The role of a message.
- public enum Role: String, Codable, Equatable {
+ public enum Role: String, Codable, Equatable, Sendable {
case system
case user
case assistant
+ // There is no `tool` role
+ // because tool calls and results are stored in the assistant messages.
}
/// A function call that can be made by the bot.
- public struct FunctionCall: Codable, Equatable {
+ public struct FunctionCall: Codable, Equatable, Sendable {
/// The name of the function.
public var name: String
/// Arguments in the format of a JSON string.
@@ -25,7 +27,7 @@ public struct ChatMessage: Equatable, Codable {
}
/// A tool call that can be made by the bot.
- public struct ToolCall: Codable, Equatable, Identifiable {
+ public struct ToolCall: Codable, Equatable, Identifiable, Sendable {
public var id: String
/// The type of tool call.
public var type: String
@@ -47,7 +49,7 @@ public struct ChatMessage: Equatable, Codable {
}
/// The response of a tool call
- public struct ToolCallResponse: Codable, Equatable {
+ public struct ToolCallResponse: Codable, Equatable, Sendable {
/// The content of the response.
public var content: String
/// The summary of the response to display in UI.
@@ -59,10 +61,10 @@ public struct ChatMessage: Equatable, Codable {
}
/// A reference to include in a chat message.
- public struct Reference: Codable, Equatable {
+ public struct Reference: Codable, Equatable, Identifiable, Sendable {
/// The kind of reference.
- public enum Kind: Codable, Equatable {
- public enum Symbol: String, Codable {
+ public enum Kind: Codable, Equatable, Sendable {
+ public enum Symbol: String, Codable, Sendable {
case `class`
case `struct`
case `enum`
@@ -75,6 +77,7 @@ public struct ChatMessage: Equatable, Codable {
case function
case method
}
+
/// Code symbol.
case symbol(Symbol, uri: String, startLine: Int?, endLine: Int?)
/// Some text.
@@ -85,8 +88,12 @@ public struct ChatMessage: Equatable, Codable {
case textFile(uri: String)
/// Other kind of reference.
case other(kind: String)
+ /// Error case.
+ case error
}
+ @FallbackDecoding
+ public var id: String
/// The title of the reference.
public var title: String
/// The content of the reference.
@@ -96,16 +103,37 @@ public struct ChatMessage: Equatable, Codable {
public var kind: Kind
public init(
+ id: String = UUID().uuidString,
title: String,
content: String,
kind: Kind
) {
+ self.id = id
self.title = title
self.content = content
self.kind = kind
}
}
+ public struct Image: Equatable, Sendable, Codable {
+ public enum Format: String, Sendable, Codable {
+ case png = "image/png"
+ case jpeg = "image/jpeg"
+ case gif = "image/gif"
+ }
+
+ public var base64EncodedData: String
+ public var format: Format
+ public var urlString: String {
+ "data:\(format.rawValue);base64,\(base64EncodedData)"
+ }
+
+ public init(base64EncodedData: String, format: Format) {
+ self.base64EncodedData = base64EncodedData
+ self.format = format
+ }
+ }
+
/// The role of a message.
@FallbackDecoding
public var role: Role
@@ -130,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?
@@ -144,6 +172,13 @@ public struct ChatMessage: Equatable, Codable {
@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
+
/// Is the message considered empty.
public var isEmpty: Bool {
if let content, !content.isEmpty { return false }
@@ -162,7 +197,9 @@ public struct ChatMessage: Equatable, Codable {
toolCalls: [ToolCall]? = nil,
summary: String? = nil,
tokenCount: Int? = nil,
- references: [Reference] = []
+ references: [Reference] = [],
+ images: [Image] = [],
+ cacheIfPossible: Bool = false
) {
self.role = role
self.senderId = senderId
@@ -174,14 +211,20 @@ public struct ChatMessage: Equatable, Codable {
self.id = id
tokensCount = tokenCount
self.references = references
+ self.images = images
+ self.cacheIfPossible = cacheIfPossible
}
}
-public struct ReferenceKindFallback: FallbackValueProvider {
+public struct ReferenceKindFallback: FallbackValueProvider, Sendable {
public static var defaultValue: ChatMessage.Reference.Kind { .other(kind: "Unknown") }
}
-public struct ChatMessageRoleFallback: FallbackValueProvider {
+public struct ReferenceIDFallback: FallbackValueProvider, Sendable {
+ public static var defaultValue: String { UUID().uuidString }
+}
+
+public struct ChatMessageRoleFallback: FallbackValueProvider, Sendable {
public static var defaultValue: ChatMessage.Role { .user }
}
diff --git a/Tool/Sources/ChatBasic/ChatPlugin.swift b/Tool/Sources/ChatBasic/ChatPlugin.swift
new file mode 100644
index 00000000..cd5977a8
--- /dev/null
+++ b/Tool/Sources/ChatBasic/ChatPlugin.swift
@@ -0,0 +1,59 @@
+import Foundation
+
+public struct ChatPluginRequest: Sendable {
+ public var text: String
+ public var arguments: [String]
+ public var history: [ChatMessage]
+
+ public init(text: String, arguments: [String], history: [ChatMessage]) {
+ self.text = text
+ self.arguments = arguments
+ self.history = history
+ }
+}
+
+public protocol ChatPlugin {
+ typealias Response = ChatAgentResponse
+ typealias Request = ChatPluginRequest
+ static var id: String { get }
+ static var command: String { get }
+ static var name: String { get }
+ static var description: String { get }
+ // 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()
+}
+
+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/ChatTab/ChatTab.swift b/Tool/Sources/ChatTab/ChatTab.swift
index 3ca118af..e64e1728 100644
--- a/Tool/Sources/ChatTab/ChatTab.swift
+++ b/Tool/Sources/ChatTab/ChatTab.swift
@@ -1,4 +1,5 @@
import ComposableArchitecture
+import Preferences
import Foundation
import SwiftUI
@@ -37,6 +38,8 @@ public protocol ChatTabType {
/// Available builders for this chat tab.
/// It's used to generate a list of tab types for user to create.
static func chatBuilders() -> [ChatTabBuilder]
+ /// The default chat tab builder to be used in open chat
+ static func defaultChatBuilder() -> ChatTabBuilder
/// Restorable state
func restorableState() async -> Data
/// Restore state
@@ -45,6 +48,15 @@ public protocol ChatTabType {
/// It will be called only once so long as you don't call it yourself.
/// It will be called from MainActor.
func start()
+ /// Whenever the user close the tab, this method will be called.
+ func close()
+ /// Handle custom command.
+ func handleCustomCommand(_ customCommand: CustomCommand) -> Bool
+
+ /// Whether this chat tab should be the default chat tab replacement.
+ static var isDefaultChatTabReplacement: Bool { get }
+ /// Whether this chat tab can handle open chat command.
+ static var canHandleOpenChatCommand: Bool { get }
}
/// The base class for all chat tabs.
@@ -67,10 +79,10 @@ open class BaseChatTab {
public init(store: StoreOf) {
chatTabStore = store
- self.id = store.id
- self.title = store.title
-
+
Task { @MainActor in
+ self.title = store.title
+ self.id = store.id
storeObserver.observe { [weak self] in
guard let self else { return }
self.title = store.title
@@ -139,6 +151,7 @@ open class BaseChatTab {
if let tab = self as? (any ChatTabType) {
tab.start()
+ chatTabStore.send(.tabContentUpdated)
}
}
}
@@ -166,6 +179,18 @@ public struct DisabledChatTabBuilder: ChatTabBuilder {
public extension ChatTabType {
/// The name of this chat tab type.
var name: String { Self.name }
+
+ /// Default implementation that does nothing.
+ func close() {}
+
+ /// By default it can't handle custom command.
+ func handleCustomCommand(_ customCommand: CustomCommand) -> Bool { false }
+
+ static var canHandleOpenChatCommand: Bool { false }
+ static var isDefaultChatTabReplacement: Bool { false }
+ static func defaultChatBuilder() -> ChatTabBuilder {
+ DisabledChatTabBuilder(title: name)
+ }
}
/// A chat tab that does nothing.
diff --git a/Tool/Sources/ChatTab/ChatTabPool.swift b/Tool/Sources/ChatTab/ChatTabPool.swift
index fafa22cc..5f5b1c2f 100644
--- a/Tool/Sources/ChatTab/ChatTabPool.swift
+++ b/Tool/Sources/ChatTab/ChatTabPool.swift
@@ -22,8 +22,8 @@ public final class ChatTabPool {
pool[id]
}
- public func setTab(_ tab: any ChatTab) {
- pool[tab.id] = tab
+ public func setTab(_ tab: any ChatTab, forId id: String) {
+ pool[id] = tab
}
public func removeTab(of id: String) {
diff --git a/Tool/Sources/CodeDiff/CodeDiff.swift b/Tool/Sources/CodeDiff/CodeDiff.swift
index a2d64642..a15db29d 100644
--- a/Tool/Sources/CodeDiff/CodeDiff.swift
+++ b/Tool/Sources/CodeDiff/CodeDiff.swift
@@ -3,10 +3,10 @@ import SuggestionBasic
public struct CodeDiff {
public init() {}
-
+
public typealias LineDiff = CollectionDifference
- public struct SnippetDiff: Equatable {
+ public struct SnippetDiff: Equatable, CustomStringConvertible {
public struct Change: Equatable {
public var offset: Int
public var element: String
@@ -20,15 +20,53 @@ public struct CodeDiff {
public var text: String
public var diff: Diff = .unchanged
+
+ var description: String {
+ switch diff {
+ case .unchanged:
+ return text
+ case let .mutated(changes):
+ return text + " [" + changes.map { change in
+ "\(change.offset): \(change.element)"
+ }.joined(separator: " | ") + "]"
+ }
+ }
}
- public struct Section: Equatable {
+ public struct Section: Equatable, CustomStringConvertible {
+ public var oldOffset: Int
+ public var newOffset: Int
public var oldSnippet: [Line]
public var newSnippet: [Line]
public var isEmpty: Bool {
oldSnippet.isEmpty && newSnippet.isEmpty
}
+
+ public var description: String {
+ """
+ \(oldSnippet.enumerated().compactMap { item in
+ let (index, line) = item
+ let lineIndex = String(format: "%3d", oldOffset + index + 1) + " "
+ switch line.diff {
+ case .unchanged:
+ return "\(lineIndex)| \(line.description)"
+ case .mutated:
+ return "\(lineIndex)| - \(line.description)"
+ }
+ }.joined(separator: "\n"))
+ \(newSnippet.enumerated().map { item in
+ let (index, line) = item
+ let lineIndex = " " + String(format: "%3d", newOffset + index + 1)
+ switch line.diff {
+ case .unchanged:
+ return "\(lineIndex)| \(line.description)"
+ case .mutated:
+ return "\(lineIndex)| + \(line.description)"
+ }
+ }.joined(separator: "\n"))
+ """
+ }
}
public var sections: [Section]
@@ -45,6 +83,10 @@ public struct CodeDiff {
}
return nil
}
+
+ public var description: String {
+ "Diff:\n" + sections.map(\.description).joined(separator: "\n---\n") + "\n"
+ }
}
public func diff(text: String, from oldText: String) -> LineDiff {
@@ -97,11 +139,7 @@ public struct CodeDiff {
let oldLines = oldSnippet.splitByNewLine(omittingEmptySubsequences: false)
let diffByLine = newLines.difference(from: oldLines)
- let (insertions, removals) = generateDiffSections(
- oldLines: oldLines,
- newLines: newLines,
- diffByLine: diffByLine
- )
+ let groups = generateDiffSections(diffByLine)
var oldLineIndex = 0
var newLineIndex = 0
@@ -109,30 +147,63 @@ public struct CodeDiff {
var result = SnippetDiff(sections: [])
while oldLineIndex < oldLines.endIndex || newLineIndex < newLines.endIndex {
- let removalSection = removals[safe: sectionIndex]
- let insertionSection = insertions[safe: sectionIndex]
-
- // handle lines before sections
- var beforeSection = SnippetDiff.Section(oldSnippet: [], newSnippet: [])
+ guard let groupItem = groups[safe: sectionIndex] else {
+ let finishingSection = SnippetDiff.Section(
+ oldOffset: oldLineIndex,
+ newOffset: newLineIndex,
+ oldSnippet: {
+ guard oldLineIndex < oldLines.endIndex else { return [] }
+ return oldLines[oldLineIndex.. Bool {
- if end + 1 != offset { return false }
- end = offset
- lines.append(String(element))
- return true
- }
- }
-
- func generateDiffSections(
- oldLines: [Substring],
- newLines: [Substring],
- diffByLine: CollectionDifference
- ) -> (insertionSections: [DiffSection], removalSections: [DiffSection]) {
- let insertionDiffs = diffByLine.insertions
- let removalDiffs = diffByLine.removals
- var insertions = [DiffSection]()
- var removals = [DiffSection]()
- var insertionIndex = 0
- var removalIndex = 0
- var insertionUnchangedGap = 0
- var removalUnchangedGap = 0
-
- while insertionIndex < insertionDiffs.endIndex || removalIndex < removalDiffs.endIndex {
- let insertion = insertionDiffs[safe: insertionIndex]
- let removal = removalDiffs[safe: removalIndex]
-
- append(
- into: &insertions,
- change: insertion,
- index: &insertionIndex,
- unchangedGap: &insertionUnchangedGap
- ) { change in
- guard case let .insert(offset, element, _) = change else { return nil }
- return (offset, element)
- }
-
- append(
- into: &removals,
- change: removal,
- index: &removalIndex,
- unchangedGap: &removalUnchangedGap
- ) { change in
- guard case let .remove(offset, element, _) = change else { return nil }
- return (offset, element)
- }
-
- if insertionUnchangedGap > removalUnchangedGap {
- // insert empty sections to insertions
- if removalUnchangedGap > 0 {
- let count = insertionUnchangedGap - removalUnchangedGap
- let index = max(insertions.endIndex - 1, 0)
- let offset = (insertions.last?.offset ?? 0) - count
- insertions.insert(
- .init(offset: offset, end: offset, lines: []),
- at: index
- )
- insertionUnchangedGap -= removalUnchangedGap
- removalUnchangedGap = 0
- } else if removal == nil {
- removalUnchangedGap = 0
- insertionUnchangedGap = 0
- }
- } else if removalUnchangedGap > insertionUnchangedGap {
- // insert empty sections to removals
- if insertionUnchangedGap > 0 {
- let count = removalUnchangedGap - insertionUnchangedGap
- let index = max(removals.endIndex - 1, 0)
- let offset = (removals.last?.offset ?? 0) - count
- removals.insert(
- .init(offset: offset, end: offset, lines: []),
- at: index
- )
- removalUnchangedGap -= insertionUnchangedGap
- insertionUnchangedGap = 0
+private extension CodeDiff {
+ func generateDiffSections(_ diff: CollectionDifference)
+ -> [DiffGroupItem]
+ {
+ guard !diff.isEmpty else { return [] }
+
+ let removes = ChangeSection.sectioning(diff.removals)
+ let inserts = ChangeSection.sectioning(diff.insertions)
+
+ var groups = [DiffGroupItem]()
+
+ var removeOffset = 0
+ var insertOffset = 0
+ var removeIndex = 0
+ var insertIndex = 0
+
+ while removeIndex < removes.count || insertIndex < inserts.count {
+ let removeSection = removes[safe: removeIndex]
+ let insertSection = inserts[safe: insertIndex]
+
+ if let removeSection, let insertSection {
+ let ro = removeSection.offset - removeOffset
+ let io = insertSection.offset - insertOffset
+ if ro == io {
+ groups.append(.init(
+ remove: removeSection.changes.map { .init(change: $0) },
+ insert: insertSection.changes.map { .init(change: $0) }
+ ))
+ removeOffset += removeSection.changes.count
+ insertOffset += insertSection.changes.count
+ removeIndex += 1
+ insertIndex += 1
+ } else if ro < io {
+ groups.append(.init(
+ remove: removeSection.changes.map { .init(change: $0) },
+ insert: []
+ ))
+ removeOffset += removeSection.changes.count
+ removeIndex += 1
} else {
- removalUnchangedGap = 0
- insertionUnchangedGap = 0
- }
- } else {
- removalUnchangedGap = 0
- insertionUnchangedGap = 0
- }
- }
-
- return (insertions, removals)
- }
-
- func append(
- into sections: inout [DiffSection],
- change: CollectionDifference.Change?,
- index: inout Int,
- unchangedGap: inout Int,
- extract: (CollectionDifference.Change) -> (offset: Int, line: Substring)?
- ) {
- guard let change, let (offset, element) = extract(change) else { return }
- if unchangedGap == 0 {
- if !sections.isEmpty {
- let lastIndex = sections.endIndex - 1
- if !sections[lastIndex]
- .appendIfPossible(offset: offset, element: element)
- {
- unchangedGap = offset - sections[lastIndex].end - 1
- sections.append(.init(
- offset: offset,
- end: offset,
- lines: [String(element)]
+ groups.append(.init(
+ remove: [],
+ insert: insertSection.changes.map { .init(change: $0) }
))
+ insertOffset += insertSection.changes.count
+ insertIndex += 1
}
- } else {
- sections.append(.init(
- offset: offset,
- end: offset,
- lines: [String(element)]
+ } else if let removeSection {
+ groups.append(.init(
+ remove: removeSection.changes.map { .init(change: $0) },
+ insert: []
))
- unchangedGap = offset
+ removeIndex += 1
+ } else if let insertSection {
+ groups.append(.init(
+ remove: [],
+ insert: insertSection.changes.map { .init(change: $0) }
+ ))
+ insertIndex += 1
}
- index += 1
}
+
+ return groups
}
}
-extension Array {
+private extension Array {
subscript(safe index: Int) -> Element? {
guard index >= 0, index < count else { return nil }
return self[index]
@@ -318,6 +333,76 @@ extension Array {
}
}
+private extension CollectionDifference.Change {
+ var offset: Int {
+ switch self {
+ case let .insert(offset, _, _):
+ return offset
+ case let .remove(offset, _, _):
+ return offset
+ }
+ }
+}
+
+private struct DiffGroupItem {
+ struct Item {
+ var offset: Int
+ var element: Element
+
+ init(offset: Int, element: Element) {
+ self.offset = offset
+ self.element = element
+ }
+
+ init(change: CollectionDifference.Change) {
+ offset = change.offset
+ switch change {
+ case let .insert(_, element, _):
+ self.element = element
+ case let .remove(_, element, _):
+ self.element = element
+ }
+ }
+ }
+
+ var remove: [Item]
+ var insert: [Item]
+}
+
+private struct ChangeSection {
+ var offset: Int { changes.first?.offset ?? 0 }
+ var changes: [CollectionDifference.Change]
+
+ static func sectioning(_ changes: [CollectionDifference.Change]) -> [Self] {
+ guard !changes.isEmpty else { return [] }
+
+ let sortedChanges = changes.sorted { $0.offset < $1.offset }
+ var sections = [Self]()
+ var currentSection = [CollectionDifference.Change]()
+
+ for change in sortedChanges {
+ if let lastOffset = currentSection.last?.offset {
+ if change.offset == lastOffset + 1 {
+ currentSection.append(change)
+ } else {
+ sections.append(Self(changes: currentSection))
+ currentSection.removeAll()
+ currentSection.append(change)
+ }
+ } else {
+ currentSection.append(change)
+ continue
+ }
+ }
+
+ if !currentSection.isEmpty {
+ sections.append(Self(changes: currentSection))
+ }
+
+ return sections
+ }
+}
+
#if DEBUG
import SwiftUI
@@ -357,7 +442,6 @@ struct SnippetDiffPreview: View {
func generateTexts() -> (original: [AttributedString], new: [AttributedString]) {
let diff = CodeDiff().diff(snippet: newCode, from: originalCode)
-
let new = diff.sections.flatMap {
$0.newSnippet.map {
let text = $0.text.trimmingCharacters(in: .newlines)
@@ -490,5 +574,35 @@ struct LineDiffPreview: View {
return SnippetDiffPreview(originalCode: originalCode, newCode: newCode)
}
+#Preview("Code Diff Editor") {
+ struct V: View {
+ @State var originalCode = ""
+ @State var newCode = ""
+
+ var body: some View {
+ VStack {
+ HStack {
+ VStack {
+ Text("Original")
+ TextEditor(text: $originalCode)
+ .frame(width: 300, height: 200)
+ }
+ VStack {
+ Text("New")
+ TextEditor(text: $newCode)
+ .frame(width: 300, height: 200)
+ }
+ }
+ .font(.body.monospaced())
+ SnippetDiffPreview(originalCode: originalCode, newCode: newCode)
+ }
+ .padding()
+ .frame(height: 600)
+ }
+ }
+
+ return V()
+}
+
#endif
diff --git a/Tool/Sources/CodeiumService/ChatTab/CodeiumChatBrowser.swift b/Tool/Sources/CodeiumService/ChatTab/CodeiumChatBrowser.swift
index 46ec9e51..5d7fab76 100644
--- a/Tool/Sources/CodeiumService/ChatTab/CodeiumChatBrowser.swift
+++ b/Tool/Sources/CodeiumService/ChatTab/CodeiumChatBrowser.swift
@@ -61,7 +61,7 @@ struct CodeiumChatBrowser {
case .loadCurrentWorkspace:
return .run { send in
- guard let workspaceURL = await XcodeInspector.shared.safe.activeWorkspaceURL
+ guard let workspaceURL = await XcodeInspector.shared.activeWorkspaceURL
else {
await send(.presentError("Can't find workspace."))
return
diff --git a/Tool/Sources/CodeiumService/ChatTab/CodeiumChatTab.swift b/Tool/Sources/CodeiumService/ChatTab/CodeiumChatTab.swift
index 8a40da2b..1a889dfe 100644
--- a/Tool/Sources/CodeiumService/ChatTab/CodeiumChatTab.swift
+++ b/Tool/Sources/CodeiumService/ChatTab/CodeiumChatTab.swift
@@ -10,6 +10,8 @@ import XcodeInspector
public class CodeiumChatTab: ChatTab {
public static var name: String { "Codeium Chat" }
+ public static var isDefaultChatTabReplacement: Bool { false }
+ public static var canHandleOpenChatCommand: Bool { true }
struct RestorableState: Codable {}
@@ -85,7 +87,7 @@ public class CodeiumChatTab: ChatTab {
chatTabStore.send(.updateTitle("Codeium Chat"))
store.send(.initialize)
- do {
+ Task { @MainActor in
var previousURL: URL?
observer.observe { [weak self] in
guard let self else { return }
@@ -98,11 +100,13 @@ public class CodeiumChatTab: ChatTab {
}
}
- observer.observe { [weak self] in
- guard let self, !store.title.isEmpty else { return }
- let title = store.title
- Task { @MainActor in
- self.chatTabStore.send(.updateTitle(title))
+ Task { @MainActor in
+ observer.observe { [weak self] in
+ guard let self, !store.title.isEmpty else { return }
+ let title = store.title
+ Task { @MainActor in
+ self.chatTabStore.send(.updateTitle(title))
+ }
}
}
}
@@ -143,7 +147,7 @@ public class CodeiumChatTab: ChatTab {
[Builder(title: "Codeium Chat")]
}
- public static func defaultChatBuilder() -> ChatTabBuilder {
+ public static func defaultChatBuilder() -> ChatTabBuilder {
Builder(title: "Codeium Chat")
}
}
diff --git a/Tool/Sources/CodeiumService/CodeiumExtension.swift b/Tool/Sources/CodeiumService/CodeiumExtension.swift
index 8a159ffb..d2e265a4 100644
--- a/Tool/Sources/CodeiumService/CodeiumExtension.swift
+++ b/Tool/Sources/CodeiumService/CodeiumExtension.swift
@@ -13,13 +13,11 @@ import Workspace
public final class CodeiumExtension: BuiltinExtension {
public var extensionIdentifier: String { "com.codeium" }
-
- public var suggestionServiceId: Preferences.BuiltInSuggestionFeatureProvider { .codeium }
public let suggestionService: CodeiumSuggestionService
-
- public var chatTabTypes: [any ChatTab.Type] {
- [CodeiumChatTab.self]
+
+ public var chatTabTypes: [any CustomChatTab] {
+ [TypedCustomChatTab(of: CodeiumChatTab.self)]
}
private var extensionUsage = ExtensionUsage(
diff --git a/Tool/Sources/CodeiumService/LanguageServer/CodeiumInstallationManager.swift b/Tool/Sources/CodeiumService/LanguageServer/CodeiumInstallationManager.swift
index 7f2c8866..29936bf8 100644
--- a/Tool/Sources/CodeiumService/LanguageServer/CodeiumInstallationManager.swift
+++ b/Tool/Sources/CodeiumService/LanguageServer/CodeiumInstallationManager.swift
@@ -3,7 +3,8 @@ import Terminal
public struct CodeiumInstallationManager {
private static var isInstalling = false
- static let latestSupportedVersion = "1.8.83"
+ static let latestSupportedVersion = "1.48.2"
+ static let minimumSupportedVersion = "1.20.0"
public init() {}
@@ -60,7 +61,7 @@ public struct CodeiumInstallationManager {
public enum InstallationStatus {
case notInstalled
case installed(String)
- case outdated(current: String, latest: String)
+ case outdated(current: String, latest: String, mandatory: Bool)
case unsupported(current: String, latest: String)
}
@@ -87,14 +88,33 @@ public struct CodeiumInstallationManager {
{
switch version.compare(targetVersion, options: .numeric) {
case .orderedAscending:
- return .outdated(current: version, latest: targetVersion)
+ switch version.compare(Self.minimumSupportedVersion) {
+ case .orderedAscending:
+ return .outdated(
+ current: version,
+ latest: Self.latestSupportedVersion,
+ mandatory: true
+ )
+ case .orderedSame:
+ return .outdated(
+ current: version,
+ latest: Self.latestSupportedVersion,
+ mandatory: false
+ )
+ case .orderedDescending:
+ return .outdated(
+ current: version,
+ latest: Self.latestSupportedVersion,
+ mandatory: false
+ )
+ }
case .orderedSame:
return .installed(version)
case .orderedDescending:
return .unsupported(current: version, latest: targetVersion)
}
}
- return .outdated(current: "Unknown", latest: Self.latestSupportedVersion)
+ return .outdated(current: "Unknown", latest: Self.latestSupportedVersion, mandatory: false)
}
public enum InstallationStep {
diff --git a/Tool/Sources/CodeiumService/LanguageServer/CodeiumLanguageServer.swift b/Tool/Sources/CodeiumService/LanguageServer/CodeiumLanguageServer.swift
index c3f83118..051994b9 100644
--- a/Tool/Sources/CodeiumService/LanguageServer/CodeiumLanguageServer.swift
+++ b/Tool/Sources/CodeiumService/LanguageServer/CodeiumLanguageServer.swift
@@ -387,7 +387,7 @@ class WorkspaceParser: NSObject, XMLParserDelegate {
}
public func getProjectPaths() async -> [String] {
- guard let workspaceURL = await XcodeInspector.shared.safe.realtimeActiveWorkspaceURL else {
+ guard let workspaceURL = XcodeInspector.shared.realtimeActiveWorkspaceURL else {
return []
}
diff --git a/Tool/Sources/CodeiumService/LanguageServer/CodeiumModels.swift b/Tool/Sources/CodeiumService/LanguageServer/CodeiumModels.swift
index d6af5d25..486e5f45 100644
--- a/Tool/Sources/CodeiumService/LanguageServer/CodeiumModels.swift
+++ b/Tool/Sources/CodeiumService/LanguageServer/CodeiumModels.swift
@@ -72,9 +72,7 @@ struct CompletionPart: Codable {
}
struct CodeiumDocument: Codable {
- var absolute_path: String
- // Path relative to the root of the workspace.
- var relative_path: String
+ var absolute_path_migrate_me_to_uri: String
var text: String
// Language ID provided by the editor.
var editor_language: String
diff --git a/Tool/Sources/CodeiumService/Services/CodeiumService.swift b/Tool/Sources/CodeiumService/Services/CodeiumService.swift
index 3aa03540..046d2df2 100644
--- a/Tool/Sources/CodeiumService/Services/CodeiumService.swift
+++ b/Tool/Sources/CodeiumService/Services/CodeiumService.swift
@@ -102,7 +102,7 @@ public class CodeiumService {
languageServerVersion = version
case .notInstalled:
throw CodeiumError.languageServerNotInstalled
- case let .outdated(version, _):
+ case let .outdated(version, _, _):
languageServerVersion = version
throw CodeiumError.languageServerOutdated
}
@@ -208,7 +208,7 @@ extension CodeiumService {
}
throw E()
}
- var ideVersion = await XcodeInspector.shared.safe.latestActiveXcode?.version
+ var ideVersion = await XcodeInspector.shared.latestActiveXcode?.version
?? fallbackXcodeVersion
let versionNumberSegmentCount = ideVersion.split(separator: ".").count
if versionNumberSegmentCount == 2 {
@@ -257,14 +257,12 @@ extension CodeiumService: CodeiumSuggestionServiceType {
requestCounter += 1
let languageId = languageIdentifierFromFileURL(fileURL)
- let relativePath = getRelativePath(of: fileURL)
let task = Task {
let request = try await CodeiumRequest.GetCompletion(requestBody: .init(
metadata: getMetadata(),
document: .init(
- absolute_path: fileURL.path,
- relative_path: relativePath,
+ absolute_path_migrate_me_to_uri: fileURL.path,
text: content,
editor_language: languageId.rawValue,
language: .init(codeLanguage: languageId),
@@ -278,8 +276,7 @@ extension CodeiumService: CodeiumSuggestionServiceType {
.map { openedDocument in
let languageId = languageIdentifierFromFileURL(openedDocument.url)
return .init(
- absolute_path: openedDocument.url.path,
- relative_path: openedDocument.relativePath,
+ absolute_path_migrate_me_to_uri: openedDocument.url.path,
text: openedDocument.content,
editor_language: languageId.rawValue,
language: .init(codeLanguage: languageId)
@@ -417,11 +414,9 @@ extension CodeiumService: CodeiumSuggestionServiceType {
workspaceURL: URL
) async throws {
let languageId = languageIdentifierFromFileURL(fileURL)
- let relativePath = getRelativePath(of: fileURL)
let request = await CodeiumRequest.RefreshContextForIdeAction(requestBody: .init(
active_document: .init(
- absolute_path: fileURL.path,
- relative_path: relativePath,
+ absolute_path_migrate_me_to_uri: fileURL.path,
text: content,
editor_language: languageId.rawValue,
language: .init(codeLanguage: languageId),
diff --git a/Tool/Sources/CommandHandler/CommandHandler.swift b/Tool/Sources/CommandHandler/CommandHandler.swift
index afba1e96..f5067668 100644
--- a/Tool/Sources/CommandHandler/CommandHandler.swift
+++ b/Tool/Sources/CommandHandler/CommandHandler.swift
@@ -1,5 +1,7 @@
+import ComposableArchitecture
import Dependencies
import Foundation
+import ModificationBasic
import Preferences
import SuggestionBasic
import Toast
@@ -14,6 +16,7 @@ public protocol CommandHandler {
func presentNextSuggestion() async
func rejectSuggestions() async
func acceptSuggestion() async
+ func acceptActiveSuggestionLineInGroup(atIndex index: Int?) async
func dismissSuggestion() async
func generateRealtimeSuggestions(sourceEditor: SourceEditor?) async
@@ -22,9 +25,10 @@ public protocol CommandHandler {
func openChat(forceDetach: Bool, activateThisApp: Bool)
func sendChatMessage(_ message: String) async
- // MARK: Prompt to Code
+ // MARK: Modification
- func acceptPromptToCode() async
+ func acceptModification() async
+ func presentModification(state: Shared) async
// MARK: Custom Command
@@ -33,11 +37,24 @@ public protocol CommandHandler {
// MARK: Toast
func toast(_ string: String, as type: ToastType)
+
+ // MARK: Others
+
+ func presentFile(at fileURL: URL, line: Int?) async
+
+ func presentFile(at fileURL: URL) async
+}
+
+public extension CommandHandler {
+ /// Default implementation for `presentFile(at:line:)`.
+ func presentFile(at fileURL: URL) async {
+ await presentFile(at: fileURL, line: nil)
+ }
}
public struct CommandHandlerDependencyKey: DependencyKey {
public static var liveValue: CommandHandler = UniversalCommandHandler.shared
- public static var testValue: CommandHandler = NoopCommandHandler()
+ public static var testValue: CommandHandler = NOOPCommandHandler()
}
public extension DependencyValues {
@@ -52,10 +69,10 @@ public extension DependencyValues {
}
public final class UniversalCommandHandler: CommandHandler {
- public static let shared: UniversalCommandHandler = UniversalCommandHandler()
-
- public var commandHandler: CommandHandler = NoopCommandHandler()
-
+ public static let shared: UniversalCommandHandler = .init()
+
+ public var commandHandler: CommandHandler = NOOPCommandHandler()
+
private init() {}
public func presentSuggestions(_ suggestions: [SuggestionBasic.CodeSuggestion]) async {
@@ -77,6 +94,10 @@ public final class UniversalCommandHandler: CommandHandler {
public func acceptSuggestion() async {
await commandHandler.acceptSuggestion()
}
+
+ public func acceptActiveSuggestionLineInGroup(atIndex index: Int?) async {
+ await commandHandler.acceptActiveSuggestionLineInGroup(atIndex: index)
+ }
public func dismissSuggestion() async {
await commandHandler.dismissSuggestion()
@@ -94,8 +115,12 @@ public final class UniversalCommandHandler: CommandHandler {
await commandHandler.sendChatMessage(message)
}
- public func acceptPromptToCode() async {
- await commandHandler.acceptPromptToCode()
+ public func acceptModification() async {
+ await commandHandler.acceptModification()
+ }
+
+ public func presentModification(state: Shared) async {
+ await commandHandler.presentModification(state: state)
}
public func handleCustomCommand(_ command: CustomCommand) async {
@@ -105,20 +130,71 @@ public final class UniversalCommandHandler: CommandHandler {
public func toast(_ string: String, as type: ToastType) {
commandHandler.toast(string, as: type)
}
+
+ public func presentFile(at fileURL: URL, line: Int?) async {
+ await commandHandler.presentFile(at: fileURL, line: line)
+ }
}
-struct NoopCommandHandler: CommandHandler {
- func presentSuggestions(_: [CodeSuggestion]) async {}
- func presentPreviousSuggestion() async {}
- func presentNextSuggestion() async {}
- func rejectSuggestions() async {}
- func acceptSuggestion() async {}
- func dismissSuggestion() async {}
- func generateRealtimeSuggestions(sourceEditor: SourceEditor?) async {}
- func openChat(forceDetach: Bool, activateThisApp: Bool) {}
- func sendChatMessage(_: String) async {}
- func acceptPromptToCode() async {}
- func handleCustomCommand(_: CustomCommand) async {}
- func toast(_: String, as: ToastType) {}
+struct NOOPCommandHandler: CommandHandler {
+ func presentSuggestions(_ suggestions: [CodeSuggestion]) async {
+ print("present \(suggestions.count) suggestions")
+ }
+
+ func presentPreviousSuggestion() async {
+ print("previous suggestion")
+ }
+
+ func presentNextSuggestion() async {
+ print("next suggestion")
+ }
+
+ func rejectSuggestions() async {
+ print("reject suggestions")
+ }
+
+ func acceptSuggestion() async {
+ print("accept suggestion")
+ }
+
+ func acceptActiveSuggestionLineInGroup(atIndex index: Int?) async {
+ print("accept active suggestion line in group at index \(String(describing: index))")
+ }
+
+ func dismissSuggestion() async {
+ print("dismiss suggestion")
+ }
+
+ func generateRealtimeSuggestions(sourceEditor: SourceEditor?) async {
+ print("generate realtime suggestions")
+ }
+
+ func openChat(forceDetach: Bool, activateThisApp: Bool) {
+ print("open chat")
+ }
+
+ func sendChatMessage(_: String) async {
+ print("send chat message")
+ }
+
+ func acceptModification() async {
+ print("accept prompt to code")
+ }
+
+ func presentModification(state: Shared) {
+ print("present modification")
+ }
+
+ func handleCustomCommand(_: CustomCommand) async {
+ print("handle custom command")
+ }
+
+ func toast(_: String, as: ToastType) {
+ print("toast")
+ }
+
+ func presentFile(at fileURL: URL, line: Int?) async {
+ print("present file")
+ }
}
diff --git a/Tool/Sources/CustomAsyncAlgorithms/TimedDebounce.swift b/Tool/Sources/CustomAsyncAlgorithms/TimedDebounce.swift
index df296cc8..bb97d82f 100644
--- a/Tool/Sources/CustomAsyncAlgorithms/TimedDebounce.swift
+++ b/Tool/Sources/CustomAsyncAlgorithms/TimedDebounce.swift
@@ -46,18 +46,27 @@ public extension AsyncSequence {
///
/// In the future when we drop macOS 12 support we should just use chunked from AsyncAlgorithms.
func timedDebounce(
- for duration: TimeInterval
+ for duration: TimeInterval,
+ reducer: @escaping @Sendable (Element, Element) -> Element
) -> AsyncThrowingStream {
return AsyncThrowingStream { continuation in
Task {
- let function = TimedDebounceFunction(duration: duration) { value in
- continuation.yield(value)
- }
+ let storage = TimedDebounceStorage()
+ var lastTimeStamp = Date()
do {
for try await value in self {
- await function(value)
+ await storage.reduce(value, reducer: reducer)
+ let now = Date()
+ if now.timeIntervalSince(lastTimeStamp) >= duration {
+ lastTimeStamp = now
+ if let value = await storage.consume() {
+ continuation.yield(value)
+ }
+ }
+ }
+ if let value = await storage.consume() {
+ continuation.yield(value)
}
- await function.finish()
continuation.finish()
} catch {
continuation.finish(throwing: error)
@@ -67,3 +76,19 @@ public extension AsyncSequence {
}
}
+private actor TimedDebounceStorage {
+ var value: Element?
+ func reduce(_ value: Element, reducer: (Element, Element) -> Element) async {
+ if let existing = self.value {
+ self.value = reducer(existing, value)
+ } else {
+ self.value = value
+ }
+ }
+
+ func consume() -> Element? {
+ defer { value = nil }
+ return value
+ }
+}
+
diff --git a/Core/Sources/ChatService/CustomCommandTemplateProcessor.swift b/Tool/Sources/CustomCommandTemplateProcessor/CustomCommandTemplateProcessor.swift
similarity index 90%
rename from Core/Sources/ChatService/CustomCommandTemplateProcessor.swift
rename to Tool/Sources/CustomCommandTemplateProcessor/CustomCommandTemplateProcessor.swift
index 2a54d320..891c8301 100644
--- a/Core/Sources/ChatService/CustomCommandTemplateProcessor.swift
+++ b/Tool/Sources/CustomCommandTemplateProcessor/CustomCommandTemplateProcessor.swift
@@ -39,8 +39,8 @@ public struct CustomCommandTemplateProcessor {
}
func getEditorInformation() async -> EditorInformation {
- let editorContent = await XcodeInspector.shared.safe.focusedEditor?.getContent()
- let documentURL = await XcodeInspector.shared.safe.activeDocumentURL
+ let editorContent = await XcodeInspector.shared.latestFocusedEditor?.getContent()
+ let documentURL = await XcodeInspector.shared.activeDocumentURL
let language = documentURL.map(languageIdentifierFromFileURL) ?? .plaintext
return .init(
diff --git a/Tool/Sources/DebounceFunction/ThrottleFunction.swift b/Tool/Sources/DebounceFunction/ThrottleFunction.swift
index 3a0771c4..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
}
@@ -40,3 +40,40 @@ public actor ThrottleFunction {
}
}
+public actor ThrottleRunner {
+ let duration: TimeInterval
+ var lastFinishTime: Date = .init(timeIntervalSince1970: 0)
+ var now: () -> Date = { Date() }
+ var task: Task?
+
+ public init(duration: TimeInterval) {
+ self.duration = duration
+ }
+
+ 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 @Sendable () async -> Void) {
+ task = Task.detached { [weak self] in
+ guard let self else { return }
+ do {
+ if wait {
+ try await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000))
+ }
+ await block()
+ await finishTask()
+ } catch {
+ await finishTask()
+ }
+ }
+ }
+
+ func finishTask() {
+ task = nil
+ lastFinishTime = now()
+ }
+}
+
diff --git a/Tool/Sources/FileSystem/ByteString.swift b/Tool/Sources/FileSystem/ByteString.swift
index af4a3b45..6f974113 100644
--- a/Tool/Sources/FileSystem/ByteString.swift
+++ b/Tool/Sources/FileSystem/ByteString.swift
@@ -143,7 +143,7 @@ extension ByteString: ByteStreamable {
}
}
-/// StringLiteralConvertable conformance for a ByteString.
+/// StringLiteralConvertible conformance for a ByteString.
extension ByteString: ExpressibleByStringLiteral {
public typealias UnicodeScalarLiteralType = StringLiteralType
public typealias ExtendedGraphemeClusterLiteralType = StringLiteralType
diff --git a/Tool/Sources/FileSystem/Lock.swift b/Tool/Sources/FileSystem/Lock.swift
index 695494af..88160822 100644
--- a/Tool/Sources/FileSystem/Lock.swift
+++ b/Tool/Sources/FileSystem/Lock.swift
@@ -1,7 +1,7 @@
import Foundation
public enum ProcessLockError: Error {
- case unableToAquireLock(errno: Int32)
+ case unableToAcquireLock(errno: Int32)
}
extension ProcessLockError: CustomNSError {
@@ -42,7 +42,7 @@ public final class FileLock {
self.init(at: cachePath.appending(component: name + ".lock"))
}
- /// Try to acquire a lock. This method will block until lock the already aquired by other process.
+ /// Try to acquire a lock. This method will block until lock the already acquired by other process.
///
/// Note: This method can throw if underlying POSIX methods fail.
public func lock(type: LockType = .exclusive, blocking: Bool = true) throws {
@@ -78,7 +78,7 @@ public final class FileLock {
}
if !LockFileEx(handle, DWORD(dwFlags), 0,
UInt32.max, UInt32.max, &overlapped) {
- throw ProcessLockError.unableToAquireLock(errno: Int32(GetLastError()))
+ throw ProcessLockError.unableToAcquireLock(errno: Int32(GetLastError()))
}
#else
// Open the lock file.
@@ -97,14 +97,14 @@ public final class FileLock {
if !blocking {
flags |= LOCK_NB
}
- // Aquire lock on the file.
+ // Acquire lock on the file.
while true {
if flock(fileDescriptor!, flags) == 0 {
break
}
// Retry if interrupted.
if errno == EINTR { continue }
- throw ProcessLockError.unableToAquireLock(errno: errno)
+ throw ProcessLockError.unableToAcquireLock(errno: errno)
}
#endif
}
diff --git a/Tool/Sources/FileSystem/Path.swift b/Tool/Sources/FileSystem/Path.swift
index b65a22b9..d32c7f32 100644
--- a/Tool/Sources/FileSystem/Path.swift
+++ b/Tool/Sources/FileSystem/Path.swift
@@ -898,7 +898,7 @@ extension AbsolutePath {
public func relative(to base: AbsolutePath) -> RelativePath {
let result: RelativePath
// Split the two paths into their components.
- // FIXME: The is needs to be optimized to avoid unncessary copying.
+ // FIXME: The is needs to be optimized to avoid unnecessary copying.
let pathComps = self.components
let baseComps = base.components
diff --git a/Tool/Sources/FileSystem/WritableByteStream.swift b/Tool/Sources/FileSystem/WritableByteStream.swift
index 94dd033d..3dcc4eff 100644
--- a/Tool/Sources/FileSystem/WritableByteStream.swift
+++ b/Tool/Sources/FileSystem/WritableByteStream.swift
@@ -9,7 +9,7 @@
*/
/// Closable entity is one that manages underlying resources and needs to be closed for cleanup
-/// The intent of this method is for the sole owner of the refernece/handle of the resource to close it completely, comapred to releasing a shared resource.
+/// The intent of this method is for the sole owner of the refernece/handle of the resource to close it completely, compared to releasing a shared resource.
public protocol Closable {
func close() throws
}
@@ -156,7 +156,7 @@ extension WritableByteStream {
// MARK: helpers that return `self`
- // FIXME: This override shouldn't be necesary but removing it causes a 30% performance regression. This problem is
+ // FIXME: This override shouldn't be necessary but removing it causes a 30% performance regression. This problem is
// tracked by the following bug: https://bugs.swift.org/browse/SR-8535
@discardableResult
public func send(_ value: ArraySlice) -> WritableByteStream {
@@ -408,7 +408,7 @@ precedencegroup StreamingPrecedence {
// MARK: Output Operator Implementations
-// FIXME: This override shouldn't be necesary but removing it causes a 30% performance regression. This problem is
+// FIXME: This override shouldn't be necessary but removing it causes a 30% performance regression. This problem is
// tracked by the following bug: https://bugs.swift.org/browse/SR-8535
@available(*, deprecated, message: "use send(_:) function on WritableByteStream instead")
diff --git a/Tool/Sources/FocusedCodeFinder/ActiveDocumentContext.swift b/Tool/Sources/FocusedCodeFinder/ActiveDocumentContext.swift
index b5fe90cf..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
@@ -80,6 +80,21 @@ public struct ActiveDocumentContext {
self.includes = includes
self.focusedContext = focusedContext
}
+
+ public static func empty() -> ActiveDocumentContext {
+ .init(
+ documentURL: .init(fileURLWithPath: "/"),
+ relativePath: "",
+ language: .builtIn(.swift),
+ fileContent: "",
+ lines: [],
+ selectedCode: "",
+ selectionRange: .outOfScope,
+ lineAnnotations: [],
+ imports: [],
+ includes: []
+ )
+ }
public mutating func moveToFocusedCode() {
moveToCodeContainingRange(selectionRange)
diff --git a/Tool/Sources/FocusedCodeFinder/ObjectiveC/ObjectiveCSyntax.swift b/Tool/Sources/FocusedCodeFinder/ObjectiveC/ObjectiveCSyntax.swift
index 98f5307a..f49c3280 100644
--- a/Tool/Sources/FocusedCodeFinder/ObjectiveC/ObjectiveCSyntax.swift
+++ b/Tool/Sources/FocusedCodeFinder/ObjectiveC/ObjectiveCSyntax.swift
@@ -75,7 +75,7 @@ enum ObjectiveCNodeType: String {
/// `__GENERICS` in category interface and implementation.
case genericsTypeReference = "generics_type_reference"
/// `IB_DESIGNABLE`, etc. The typo is from the original source.
- case classInterfaceAttributeSpecifier = "class_interface_attribute_sepcifier"
+ case classInterfaceAttributeSpecifier = "class_interface_attribute_specifier"
}
extension ObjectiveCNodeType {
diff --git a/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift
index 3222f7c0..606499a4 100644
--- a/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift
+++ b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift
@@ -1,15 +1,15 @@
import BuiltinExtension
import CopilotForXcodeKit
+import Dependencies
import Foundation
import LanguageServerProtocol
import Logger
import Preferences
+import Toast
import Workspace
public final class GitHubCopilotExtension: BuiltinExtension {
public var extensionIdentifier: String { "com.github.copilot" }
-
- public var suggestionServiceId: Preferences.BuiltInSuggestionFeatureProvider { .gitHubCopilot }
public let suggestionService: GitHubCopilotSuggestionService
public let chatService: GitHubCopilotChatService
@@ -22,6 +22,8 @@ public final class GitHubCopilotExtension: BuiltinExtension {
extensionUsage.isSuggestionServiceInUse || extensionUsage.isChatServiceInUse
}
+ @Dependency(\.toastController) var toast
+
let workspacePool: WorkspacePool
let serviceLocator: ServiceLocatorType
@@ -51,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)
}
@@ -141,7 +153,6 @@ protocol ServiceLocatorType {
class ServiceLocator: ServiceLocatorType {
let workspacePool: WorkspacePool
-
init(workspacePool: WorkspacePool) {
self.workspacePool = workspacePool
}
@@ -154,3 +165,168 @@ class ServiceLocator: ServiceLocatorType {
}
}
+extension GitHubCopilotExtension {
+ public struct Token: Codable {
+// let codesearch: Bool
+ public let individual: Bool
+ public let endpoints: Endpoints
+ public let chat_enabled: Bool
+// public let sku: String
+// public let copilotignore_enabled: Bool
+// public let limited_user_quotas: String?
+// public let tracking_id: String
+// public let xcode: Bool
+// public let limited_user_reset_date: String?
+// public let telemetry: String
+// public let prompt_8k: Bool
+ public let token: String
+// public let nes_enabled: Bool
+// public let vsc_electron_fetcher_v2: Bool
+// public let code_review_enabled: Bool
+// public let annotations_enabled: Bool
+// public let chat_jetbrains_enabled: Bool
+// public let xcode_chat: Bool
+// public let refresh_in: Int
+// public let snippy_load_test_enabled: Bool
+// public let trigger_completion_after_accept: Bool
+ public let expires_at: Int
+// public let public_suggestions: String
+// public let code_quote_enabled: Bool
+
+ public struct Endpoints: Codable {
+ public let api: String
+ public let proxy: String
+ public let telemetry: String
+// public let origin-tracker: String
+ }
+ }
+
+ struct AuthInfo: Codable {
+ public let user: String
+ public let oauth_token: String
+ public let githubAppId: String
+ }
+
+ static var authInfo: AuthInfo? {
+ guard let urls = try? GitHubCopilotBaseService.createFoldersIfNeeded()
+ else { return nil }
+ let path = urls.supportURL
+ .appendingPathComponent("undefined")
+ .appendingPathComponent(".config")
+ .appendingPathComponent("github-copilot")
+ .appendingPathComponent("apps.json").path
+ guard FileManager.default.fileExists(atPath: path) else { return nil }
+
+ do {
+ let data = try Data(contentsOf: URL(fileURLWithPath: path))
+ let json = try JSONSerialization
+ .jsonObject(with: data, options: []) as? [String: [String: String]]
+ guard let firstEntry = json?.values.first else { return nil }
+ let jsonData = try JSONSerialization.data(withJSONObject: firstEntry, options: [])
+ return try JSONDecoder().decode(AuthInfo.self, from: jsonData)
+ } catch {
+ Logger.gitHubCopilot.error(error.localizedDescription)
+ return nil
+ }
+ }
+
+ @MainActor
+ static var cachedToken: Token?
+
+ public static func fetchToken() async throws -> Token {
+ guard let authToken = authInfo?.oauth_token
+ else { throw GitHubCopilotError.notLoggedIn }
+
+ let oldToken = await MainActor.run { cachedToken }
+ if let oldToken {
+ let expiresAt = Date(timeIntervalSince1970: TimeInterval(oldToken.expires_at))
+ if expiresAt > Date() {
+ return oldToken
+ }
+ }
+
+ let url = URL(string: "https://api.github.com/copilot_internal/v2/token")!
+ var request = URLRequest(url: url)
+ request.httpMethod = "GET"
+ request.setValue("token \(authToken)", forHTTPHeaderField: "authorization")
+ request.setValue("unknown-editor/0", forHTTPHeaderField: "editor-version")
+ request.setValue("unknown-editor-plugin/0", forHTTPHeaderField: "editor-plugin-version")
+ request.setValue("1.236.0", forHTTPHeaderField: "copilot-language-server-version")
+ request.setValue("GithubCopilot/1.236.0", forHTTPHeaderField: "user-agent")
+ request.setValue("*/*", forHTTPHeaderField: "accept")
+ request.setValue("gzip,deflate,br", forHTTPHeaderField: "accept-encoding")
+
+ do {
+ let (data, _) = try await URLSession.shared.data(for: request)
+ if let jsonString = String(data: data, encoding: .utf8) {
+ print(jsonString)
+ }
+ let newToken = try JSONDecoder().decode(Token.self, from: data)
+ await MainActor.run { cachedToken = newToken }
+ return newToken
+ } catch {
+ Logger.service.error(error.localizedDescription)
+ throw error
+ }
+ }
+
+ public static func fetchLLMModels() async throws -> [GitHubCopilotLLMModel] {
+ let token = try await GitHubCopilotExtension.fetchToken()
+ guard let endpoint = URL(string: token.endpoints.api + "/models") else {
+ throw CancellationError()
+ }
+ var request = URLRequest(url: endpoint)
+ request.setValue(
+ "Copilot for Xcode/\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown")",
+ forHTTPHeaderField: "Editor-Version"
+ )
+ request.setValue("Bearer \(token.token)", forHTTPHeaderField: "Authorization")
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.setValue("vscode-chat", forHTTPHeaderField: "Copilot-Integration-Id")
+ request.setValue("2023-07-07", forHTTPHeaderField: "X-Github-Api-Version")
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+
+ guard let response = response as? HTTPURLResponse else {
+ throw CancellationError()
+ }
+
+ guard response.statusCode == 200 else {
+ throw CancellationError()
+ }
+
+ struct Model: Decodable {
+ struct Limit: Decodable {
+ var max_context_window_tokens: Int
+ }
+
+ struct Capability: Decodable {
+ var type: String?
+ var family: String?
+ var limit: Limit?
+ }
+
+ var id: String
+ var capabilities: Capability
+ }
+
+ struct Body: Decodable {
+ var data: [Model]
+ }
+
+ let models = try JSONDecoder().decode(Body.self, from: data)
+ .data
+ .filter {
+ $0.capabilities.type == "chat"
+ }
+ .map {
+ GitHubCopilotLLMModel(
+ modelId: $0.id,
+ familyName: $0.capabilities.family ?? "",
+ contextWindow: $0.capabilities.limit?.max_context_window_tokens ?? 0
+ )
+ }
+ return models
+ }
+}
+
diff --git a/Tool/Sources/GitHubCopilotService/GitHubCopilotWorkspacePlugin.swift b/Tool/Sources/GitHubCopilotService/GitHubCopilotWorkspacePlugin.swift
index e893c133..405a5402 100644
--- a/Tool/Sources/GitHubCopilotService/GitHubCopilotWorkspacePlugin.swift
+++ b/Tool/Sources/GitHubCopilotService/GitHubCopilotWorkspacePlugin.swift
@@ -1,8 +1,8 @@
+import Dependencies
import Foundation
import Logger
-import Workspace
import Toast
-import Dependencies
+import Workspace
public final class GitHubCopilotWorkspacePlugin: WorkspacePlugin {
enum Error: Swift.Error, LocalizedError {
@@ -14,7 +14,7 @@ public final class GitHubCopilotWorkspacePlugin: WorkspacePlugin {
}
}
}
-
+
@Dependency(\.toast) var toast
let installationManager = GitHubCopilotInstallationManager()
@@ -32,6 +32,10 @@ public final class GitHubCopilotWorkspacePlugin: WorkspacePlugin {
return nil
} catch {
Logger.gitHubCopilot.error("Failed to create GitHub Copilot service: \(error)")
+ toast(
+ "Failed to start GitHub Copilot language server: \(error.localizedDescription)",
+ .error
+ )
return nil
}
}
@@ -74,7 +78,7 @@ public final class GitHubCopilotWorkspacePlugin: WorkspacePlugin {
}
}
}
-
+
@GitHubCopilotSuggestionActor
func updateLanguageServerIfPossible() async {
guard !GitHubCopilotInstallationManager.isInstalling else { return }
diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift
index 64b35868..817a6827 100644
--- a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift
+++ b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift
@@ -14,6 +14,7 @@ class CopilotLocalProcessServer {
private var wrappedServer: CustomJSONRPCLanguageServer?
var terminationHandler: (() -> Void)?
@MainActor var ongoingCompletionRequestIDs: [JSONId] = []
+ @MainActor var ongoingConversationRequestIDs: [String: JSONId] = [:]
public convenience init(
path: String,
@@ -58,6 +59,21 @@ class CopilotLocalProcessServer {
Task { @MainActor [weak self] in
self?.ongoingCompletionRequestIDs.append(request.id)
}
+ } else if request.method == "conversation/create" {
+ Task { @MainActor [weak self] in
+ if let paramsData = try? JSONEncoder().encode(request.params) {
+ do {
+ let params = try JSONDecoder().decode(
+ GitHubCopilotRequest.ConversationCreate.RequestBody.self,
+ from: paramsData
+ )
+ self?.ongoingConversationRequestIDs[params.workDoneToken] = request.id
+ } catch {
+ // Handle decoding error
+ print("Error decoding ConversationCreateParams: \(error)")
+ }
+ }
+ }
}
}
@@ -125,18 +141,13 @@ extension CopilotLocalProcessServer: LanguageServerProtocol.Server {
/// Cancel ongoing completion requests.
public func cancelOngoingTasks() async {
- guard let server = wrappedServer, process.isRunning else {
+ guard let _ = wrappedServer, process.isRunning else {
return
}
let task = Task { @MainActor in
for id in self.ongoingCompletionRequestIDs {
- switch id {
- case let .numericId(id):
- try? await server.sendNotification(.protocolCancelRequest(.init(id: id)))
- case let .stringId(id):
- try? await server.sendNotification(.protocolCancelRequest(.init(id: id)))
- }
+ await cancelTask(id)
}
self.ongoingCompletionRequestIDs = []
}
@@ -144,6 +155,27 @@ extension CopilotLocalProcessServer: LanguageServerProtocol.Server {
await task.value
}
+ public func cancelOngoingTask(workDoneToken: String) async {
+ let task = Task { @MainActor in
+ guard let id = ongoingConversationRequestIDs[workDoneToken] else { return }
+ await cancelTask(id)
+ }
+ await task.value
+ }
+
+ public func cancelTask(_ id: JSONId) async {
+ guard let server = wrappedServer, process.isRunning else {
+ return
+ }
+
+ switch id {
+ case let .numericId(id):
+ try? await server.sendNotification(.protocolCancelRequest(.init(id: id)))
+ case let .stringId(id):
+ try? await server.sendNotification(.protocolCancelRequest(.init(id: id)))
+ }
+ }
+
public func sendRequest(
_ request: ClientRequest,
completionHandler: @escaping (ServerResult) -> Void
@@ -328,6 +360,8 @@ final class ServerNotificationHandler {
Logger.gitHubCopilot
.info("\(anyNotification.method): \(debugDescription)")
}
+ case "didChangeStatus":
+ Logger.gitHubCopilot.info("Did change status: \(debugDescription)")
default:
throw ServerError.handlerUnavailable(methodName)
}
diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotInstallationManager.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotInstallationManager.swift
index ed11d4b2..edf59a50 100644
--- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotInstallationManager.swift
+++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotInstallationManager.swift
@@ -6,12 +6,15 @@ public struct GitHubCopilotInstallationManager {
public private(set) static var isInstalling = false
static var downloadURL: URL {
- let commitHash = "782461159655b259cff10ecff05efa761e3d4764"
+ let commitHash = "f89e977c87180519ba3b942200e3d05b17b1e2fc"
let link = "https://github.com/github/copilot.vim/archive/\(commitHash).zip"
return URL(string: link)!
}
- static let latestSupportedVersion = "1.40.0"
+ /// The GitHub's version has quite a lot of changes about `watchedFiles` since the following
+ /// commit.
+ /// https://github.com/github/CopilotForXcode/commit/a50045aa3ab3b7d532cadf40c4c10bed32f81169#diff-678798cf677bcd1ce276809cfccd33da9ff594b1b0c557180210a4ed2bd27ffa
+ static let latestSupportedVersion = "1.57.0"
static let minimumSupportedVersion = "1.32.0"
public init() {}
@@ -42,11 +45,23 @@ public struct GitHubCopilotInstallationManager {
case .orderedAscending:
switch version.compare(Self.minimumSupportedVersion) {
case .orderedAscending:
- return .outdated(current: version, latest: Self.latestSupportedVersion, mandatory: true)
+ return .outdated(
+ current: version,
+ latest: Self.latestSupportedVersion,
+ mandatory: true
+ )
case .orderedSame:
- return .outdated(current: version, latest: Self.latestSupportedVersion, mandatory: false)
+ return .outdated(
+ current: version,
+ latest: Self.latestSupportedVersion,
+ mandatory: false
+ )
case .orderedDescending:
- return .outdated(current: version, latest: Self.latestSupportedVersion, mandatory: false)
+ return .outdated(
+ current: version,
+ latest: Self.latestSupportedVersion,
+ mandatory: false
+ )
}
case .orderedSame:
return .installed(version)
@@ -136,7 +151,15 @@ public struct GitHubCopilotInstallationManager {
return
}
- let lspURL = gitFolderURL.appendingPathComponent("dist")
+ let lspURL = {
+ let caseA = gitFolderURL.appendingPathComponent("dist")
+ if FileManager.default.fileExists(atPath: caseA.path) {
+ return caseA
+ }
+ return gitFolderURL
+ .appendingPathComponent("copilot-language-server")
+ .appendingPathComponent("dist")
+ }()
let copilotURL = urls.executableURL.appendingPathComponent("copilot")
if !FileManager.default.fileExists(atPath: copilotURL.path) {
diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift
index 1b80058c..d5681c1e 100644
--- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift
+++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift
@@ -2,6 +2,7 @@ import Foundation
import JSONRPC
import LanguageServerProtocol
import SuggestionBasic
+import XcodeInspector
struct GitHubCopilotDoc: Codable {
var source: String
@@ -55,6 +56,8 @@ enum GitHubCopilotChatSource: String, Codable {
enum GitHubCopilotRequest {
struct SetEditorInfo: GitHubCopilotRequestType {
+ let xcodeVersion: String
+
struct Response: Codable {}
var networkProxy: JSONValue? {
@@ -140,14 +143,15 @@ enum GitHubCopilotRequest {
var dict: [String: JSONValue] = [
"editorInfo": pretendToBeVSCode ? .hash([
"name": "vscode",
- "version": "1.89.1",
+ "version": "1.99.3",
]) : .hash([
- "name": "xcode",
- "version": "",
+ "name": "Xcode",
+ "version": .string(xcodeVersion),
]),
"editorPluginInfo": .hash([
"name": "Copilot for Xcode",
- "version": "",
+ "version": .string(Bundle.main
+ .infoDictionary?["CFBundleShortVersionString"] as? String ?? ""),
]),
]
@@ -347,31 +351,48 @@ enum GitHubCopilotRequest {
}
struct RequestBody: Codable {
- var workDoneToken: String
- var turns: [Turn]; struct Turn: Codable {
- var request: String
- var response: String?
+ public struct Reference: Codable, Equatable, Hashable {
+ public var type: String = "file"
+ public let uri: String
+ public let position: Position?
+ public let visibleRange: SuggestionBasic.CursorRange?
+ public let selection: SuggestionBasic.CursorRange?
+ public let openedAt: String?
+ public let activeAt: String?
}
- var capabilities: Capabilities; struct Capabilities: Codable {
- var allSkills: Bool?
- var skills: [String]
+ enum ConversationSource: String, Codable {
+ case panel, inline
}
- var options: [String: String]?
- var doc: GitHubCopilotDoc?
- var computeSuggestions: Bool?
- var references: [Reference]?; struct Reference: Codable {
- var uri: String
- var position: Position?
- var visibleRange: CursorRange?
- var selectionRange: CursorRange?
- var openedAt: Date?
- var activatedAt: Date?
+ enum ConversationMode: String, Codable {
+ case agent = "Agent"
}
- var source: GitHubCopilotChatSource? // inline or panel
+ struct ConversationTurn: Codable {
+ var request: String
+ var response: String?
+ var turnId: String?
+ }
+
+ var workDoneToken: String
+ var turns: [ConversationTurn]
+ var capabilities: Capabilities
+ var textDocument: GitHubCopilotDoc?
+ var references: [Reference]?
+ var computeSuggestions: Bool?
+ var source: ConversationSource?
var workspaceFolder: String?
+ var workspaceFolders: [WorkspaceFolder]?
+ var ignoredSkills: [String]?
+ var model: String?
+ var chatMode: ConversationMode?
+ var userLanguage: String?
+
+ struct Capabilities: Codable {
+ var skills: [String]
+ var allSkills: Bool?
+ }
}
let requestBody: RequestBody
@@ -390,24 +411,13 @@ enum GitHubCopilotRequest {
var workDoneToken: String
var conversationId: String
var message: String
- var followUp: FollowUp?; struct FollowUp: Codable {
- var id: String
- var type: String
- }
-
- var options: [String: String]?
- var doc: GitHubCopilotDoc?
- var computeSuggestions: Bool?
- var references: [Reference]?; struct Reference: Codable {
- var uri: String
- var position: Position?
- var visibleRange: CursorRange?
- var selectionRange: CursorRange?
- var openedAt: Date?
- var activatedAt: Date?
- }
-
+ var textDocument: GitHubCopilotDoc?
+ var ignoredSkills: [String]?
+ var references: [ConversationCreate.RequestBody.Reference]?
+ var model: String?
var workspaceFolder: String?
+ var workspaceFolders: [WorkspaceFolder]?
+ var chatMode: String?
}
let requestBody: RequestBody
@@ -454,5 +464,42 @@ enum GitHubCopilotRequest {
return .custom("conversation/destroy", dict)
}
}
+
+ struct CopilotModels: GitHubCopilotRequestType {
+ typealias Response = [GitHubCopilotModel]
+
+ var request: ClientRequest {
+ .custom("copilot/models", .hash([:]))
+ }
+ }
+}
+
+public struct GitHubCopilotModel: Codable, Equatable {
+ public let modelFamily: String
+ public let modelName: String
+ public let id: String
+// public let modelPolicy: CopilotModelPolicy?
+ public let scopes: [GitHubCopilotPromptTemplateScope]
+ public let preview: Bool
+ public let isChatDefault: Bool
+ public let isChatFallback: Bool
+// public let capabilities: CopilotModelCapabilities
+// public let billing: CopilotModelBilling?
+}
+
+public struct GitHubCopilotLLMModel: Equatable, Decodable, Identifiable {
+ public var id: String { modelId }
+ public var modelId: String
+ public var familyName: String
+ public var contextWindow: Int
+}
+
+public enum GitHubCopilotPromptTemplateScope: String, Codable, Equatable {
+ case chatPanel = "chat-panel"
+ case editPanel = "edit-panel"
+ case agentPanel = "agent-panel"
+ case editor
+ case inline
+ case completion
}
diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift
index 9a4b83e4..486ddd92 100644
--- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift
+++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift
@@ -1,11 +1,13 @@
import AppKit
import enum CopilotForXcodeKit.SuggestionServiceError
import Foundation
+import JSONRPC
import LanguageClient
import LanguageServerProtocol
import Logger
import Preferences
import SuggestionBasic
+import XcodeInspector
public protocol GitHubCopilotAuthServiceType {
func checkStatus() async throws -> GitHubCopilotAccountStatus
@@ -34,6 +36,7 @@ public protocol GitHubCopilotSuggestionServiceType {
func notifySaveTextDocument(fileURL: URL) async throws
func cancelRequest() async
func terminate() async
+ func cancelOngoingTask(workDoneToken: String) async
}
protocol GitHubCopilotLSP {
@@ -51,6 +54,7 @@ extension GitHubCopilotLSP {
}
enum GitHubCopilotError: Error, LocalizedError {
+ case notLoggedIn
case languageServerNotInstalled
case languageServerError(ServerError)
case failedToInstallStartScript
@@ -58,6 +62,8 @@ enum GitHubCopilotError: Error, LocalizedError {
var errorDescription: String? {
switch self {
+ case .notLoggedIn:
+ return "Not logged in."
case .languageServerNotInstalled:
return "Language server is not installed."
case .failedToInstallStartScript:
@@ -77,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:
@@ -128,6 +134,9 @@ public class GitHubCopilotBaseService {
let urls = try GitHubCopilotBaseService.createFoldersIfNeeded()
let executionParams: Process.ExecutionParameters
let runner = UserDefaults.shared.value(for: \.runNodeWith)
+// let watchedFiles = JSONValue(
+// booleanLiteral: projectRootURL.path == "/" ? false : true
+// )
guard let agentJSURL = { () -> URL? in
let languageServerDotJS = urls.executableURL
@@ -165,6 +174,16 @@ public class GitHubCopilotBaseService {
}
}()
+ #if DEBUG
+ let environment: [String: String] = [
+ "GH_COPILOT_DEBUG_UI_PORT": "8080",
+ "GH_COPILOT_VERBOSE": UserDefaults.shared.value(for: \.gitHubCopilotVerboseLog)
+ ? "true" : "false",
+ ]
+ #else
+ let environment = [String: String]()
+ #endif
+
switch runner {
case .bash:
let nodePath = UserDefaults.shared.value(for: \.nodePath)
@@ -176,7 +195,7 @@ public class GitHubCopilotBaseService {
executionParams = Process.ExecutionParameters(
path: "/bin/bash",
arguments: ["-i", "-l", "-c", command],
- environment: [:],
+ environment: environment,
currentDirectoryURL: urls.supportURL
)
case .shell:
@@ -190,7 +209,7 @@ public class GitHubCopilotBaseService {
executionParams = Process.ExecutionParameters(
path: shell,
arguments: ["-i", "-l", "-c", command],
- environment: [:],
+ environment: environment,
currentDirectoryURL: urls.supportURL
)
case .env:
@@ -229,6 +248,8 @@ public class GitHubCopilotBaseService {
experimental: nil
)
+ let pretendToBeVSCode = UserDefaults.shared
+ .value(for: \.gitHubCopilotPretendIDEToBeVSCode)
return InitializeParams(
processId: Int(ProcessInfo.processInfo.processIdentifier),
clientInfo: .init(
@@ -239,10 +260,30 @@ public class GitHubCopilotBaseService {
locale: nil,
rootPath: projectRootURL.path,
rootUri: projectRootURL.path,
- initializationOptions: nil,
+ initializationOptions: [
+ "editorInfo": pretendToBeVSCode ? .hash([
+ "name": "vscode",
+ "version": "1.99.3",
+ ]) : .hash([
+ "name": "Xcode",
+ "version": .string(xcodeVersion() ?? "16.0"),
+ ]),
+ "editorPluginInfo": .hash([
+ "name": "Copilot for Xcode",
+ "version": .string(Bundle.main
+ .infoDictionary?["CFBundleShortVersionString"] as? String ?? ""),
+ ]),
+// "copilotCapabilities": [
+// /// The editor has support for watching files over LSP
+// "watchedFiles": watchedFiles,
+// ],
+ ],
capabilities: capabilities,
trace: .off,
- workspaceFolders: nil
+ workspaceFolders: [WorkspaceFolder(
+ uri: projectRootURL.absoluteString,
+ name: projectRootURL.lastPathComponent
+ )]
)
}
@@ -255,11 +296,15 @@ public class GitHubCopilotBaseService {
let notifications = NotificationCenter.default
.notifications(named: .gitHubCopilotShouldRefreshEditorInformation)
Task { [weak self] in
- _ = try? await server.sendRequest(GitHubCopilotRequest.SetEditorInfo())
+ _ = try? await server.sendRequest(
+ GitHubCopilotRequest.SetEditorInfo(xcodeVersion: xcodeVersion() ?? "16.0")
+ )
for await _ in notifications {
guard self != nil else { return }
- _ = try? await server.sendRequest(GitHubCopilotRequest.SetEditorInfo())
+ _ = try? await server.sendRequest(
+ GitHubCopilotRequest.SetEditorInfo(xcodeVersion: xcodeVersion() ?? "16.0")
+ )
}
}
}
@@ -419,7 +464,7 @@ public final class GitHubCopilotService: GitHubCopilotBaseService,
do {
let completions = try await server
.sendRequest(GitHubCopilotRequest.InlineCompletion(doc: .init(
- textDocument: .init(uri: fileURL.path, version: 1),
+ textDocument: .init(uri: fileURL.absoluteString, version: 1),
position: cursorPosition,
formattingOptions: .init(
tabSize: tabSize,
@@ -478,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()
@@ -587,6 +644,10 @@ public final class GitHubCopilotService: GitHubCopilotBaseService,
public func terminate() async {
// automatically handled
}
+
+ public func cancelOngoingTask(workDoneToken: String) async {
+ await localProcessServer?.cancelOngoingTask(workDoneToken: workDoneToken)
+ }
}
extension InitializingServer: GitHubCopilotLSP {
@@ -606,3 +667,27 @@ extension InitializingServer: GitHubCopilotLSP {
}
}
+private func xcodeVersion() -> String? {
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun")
+ process.arguments = ["xcodebuild", "-version"]
+
+ let pipe = Pipe()
+ process.standardOutput = pipe
+
+ do {
+ try process.run()
+ } catch {
+ print("Error running xcrun xcodebuild: \(error)")
+ return nil
+ }
+
+ let data = pipe.fileHandleForReading.readDataToEndOfFile()
+ guard let output = String(data: data, encoding: .utf8) else {
+ return nil
+ }
+
+ let lines = output.split(separator: "\n")
+ return lines.first?.split(separator: " ").last.map(String.init)
+}
+
diff --git a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotChatService.swift b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotChatService.swift
index 9f4062ae..280b7068 100644
--- a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotChatService.swift
+++ b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotChatService.swift
@@ -31,27 +31,43 @@ public final class GitHubCopilotChatService: BuiltinExtensionChatServiceType {
tabSize: 1,
indentSize: 4,
insertSpaces: true,
- path: editorContent?.documentURL.path ?? "",
- uri: editorContent?.documentURL.path ?? "",
+ path: editorContent?.documentURL.absoluteString ?? "",
+ uri: editorContent?.documentURL.absoluteString ?? "",
relativePath: editorContent?.relativePath ?? "",
languageId: editorContent?.language ?? .plaintext,
position: editorContent?.editorContent?.cursorPosition ?? .zero
)
-
let request = GitHubCopilotRequest.ConversationCreate(requestBody: .init(
workDoneToken: workDoneToken,
turns: turns,
- capabilities: .init(allSkills: true, skills: []),
- doc: doc,
+ capabilities: .init(skills: [], allSkills: false),
+ textDocument: doc,
source: .panel,
- workspaceFolder: workspace.projectURL.path
+ workspaceFolder: workspace.projectURL.absoluteString,
+ model: {
+ let selectedModel = UserDefaults.shared.value(for: \.gitHubCopilotModelId)
+ if selectedModel.isEmpty {
+ return nil
+ }
+ return selectedModel
+ }(),
+ userLanguage: {
+ let language = UserDefaults.shared.value(for: \.chatGPTLanguage)
+ if language.isEmpty {
+ return "Auto Detected"
+ }
+ return language
+ }()
))
let stream = AsyncThrowingStream { continuation in
let startTimestamp = Date()
continuation.onTermination = { _ in
- Task { service.unregisterNotificationHandler(id: id) }
+ Task {
+ service.unregisterNotificationHandler(id: id)
+ await service.cancelOngoingTask(workDoneToken: workDoneToken)
+ }
}
service.registerNotificationHandler(id: id) { notification, data in
@@ -72,12 +88,26 @@ public final class GitHubCopilotChatService: BuiltinExtensionChatServiceType {
if let reply = progress.value.reply, progress.value.kind == "report" {
continuation.yield(reply)
} else if progress.value.kind == "end" {
- if let error = progress.value.error,
+ if let error = progress.value.error?.message,
progress.value.cancellationReason == nil
{
- continuation.finish(
- throwing: GitHubCopilotError.chatEndsWithError(error)
- )
+ if error.contains("400") {
+ continuation.finish(
+ throwing: GitHubCopilotError.chatEndsWithError(
+ "\(error). Please try enabling pretend IDE to be VSCode and click refresh configuration."
+ )
+ )
+ } else if error.contains("No model configuration found") {
+ continuation.finish(
+ throwing: GitHubCopilotError.chatEndsWithError(
+ "\(error). Please try enabling pretend IDE to be VSCode and click refresh configuration."
+ )
+ )
+ } else {
+ continuation.finish(
+ throwing: GitHubCopilotError.chatEndsWithError(error)
+ )
+ }
} else {
continuation.finish()
}
@@ -124,7 +154,7 @@ public final class GitHubCopilotChatService: BuiltinExtensionChatServiceType {
}
extension GitHubCopilotChatService {
- typealias Turn = GitHubCopilotRequest.ConversationCreate.RequestBody.Turn
+ typealias Turn = GitHubCopilotRequest.ConversationCreate.RequestBody.ConversationTurn
func convertHistory(history: [Message], message: String) -> [Turn] {
guard let firstIndexOfUserMessage = history.firstIndex(where: { $0.role == .user })
else { return [.init(request: message, response: nil)] }
@@ -199,6 +229,15 @@ extension GitHubCopilotChatService {
var message: String
}
+ struct Error: Decodable {
+ var responseIsIncomplete: Bool?
+ var message: String?
+ }
+
+ struct Annotation: Decodable {
+ var id: Int
+ }
+
var kind: String
var title: String?
var conversationId: String
@@ -207,10 +246,10 @@ extension GitHubCopilotChatService {
var followUp: FollowUp?
var suggestedTitle: String?
var reply: String?
- var annotations: [String]?
+ var annotations: [Annotation]?
var hideText: Bool?
var cancellationReason: String?
- var error: String?
+ var error: Error?
}
var token: String
diff --git a/Tool/Sources/GitIgnoreCheck/GitIgnoreCheck.swift b/Tool/Sources/GitIgnoreCheck/GitIgnoreCheck.swift
index b2c48114..53f5bf39 100644
--- a/Tool/Sources/GitIgnoreCheck/GitIgnoreCheck.swift
+++ b/Tool/Sources/GitIgnoreCheck/GitIgnoreCheck.swift
@@ -54,9 +54,9 @@ public struct DefaultGitIgnoredChecker: GitIgnoredChecker {
do {
let result = try await terminal.runCommand(
"/bin/bash",
- arguments: ["-c", "git check-ignore \"\(fileURL.path)\""],
+ arguments: ["-c", "git check-ignore ${TARGET_FILE}"],
currentDirectoryURL: gitFolderURL,
- environment: [:]
+ environment: ["TARGET_FILE": fileURL.path]
)
if result.isEmpty { return false }
return true
@@ -76,9 +76,9 @@ public struct DefaultGitIgnoredChecker: GitIgnoredChecker {
do {
let result = try await terminal.runCommand(
"/bin/bash",
- arguments: ["-c", "git check-ignore \(filePaths)"],
+ arguments: ["-c", "git check-ignore ${TARGET_FILE}"],
currentDirectoryURL: gitFolderURL,
- environment: [:]
+ environment: ["TARGET_FILE": filePaths]
)
return result
.split(whereSeparator: \.isNewline)
diff --git a/Tool/Sources/JoinJSON/JoinJSON.swift b/Tool/Sources/JoinJSON/JoinJSON.swift
new file mode 100644
index 00000000..26181e88
--- /dev/null
+++ b/Tool/Sources/JoinJSON/JoinJSON.swift
@@ -0,0 +1,29 @@
+import Foundation
+
+public struct JoinJSON {
+ public init() {}
+
+ public func join(_ a: String, with b: String) -> Data {
+ return join(a.data(using: .utf8) ?? Data(), with: b.data(using: .utf8) ?? Data())
+ }
+
+ public func join(_ a: Data, with b: String) -> Data {
+ return join(a, with: b.data(using: .utf8) ?? Data())
+ }
+
+ public func join(_ a: Data, with b: Data) -> Data {
+ guard let firstDict = try? JSONSerialization.jsonObject(with: a) as? [String: Any],
+ let secondDict = try? JSONSerialization.jsonObject(with: b) as? [String: Any]
+ else {
+ return a
+ }
+
+ var merged = firstDict
+ for (key, value) in secondDict {
+ merged[key] = value
+ }
+
+ return (try? JSONSerialization.data(withJSONObject: merged)) ?? a
+ }
+}
+
diff --git a/Tool/Sources/LangChain/Chains/QAInformationRetrievalChain.swift b/Tool/Sources/LangChain/Chains/QAInformationRetrievalChain.swift
index f9c399f3..8b889ff7 100644
--- a/Tool/Sources/LangChain/Chains/QAInformationRetrievalChain.swift
+++ b/Tool/Sources/LangChain/Chains/QAInformationRetrievalChain.swift
@@ -11,6 +11,7 @@ public final class QAInformationRetrievalChain: Chain {
public struct Output {
public var information: String
public var sourceDocuments: [Document]
+ public var distance: [Float]
}
public init(
@@ -79,7 +80,11 @@ public final class QAInformationRetrievalChain: Chain {
callbackManagers: callbackManagers
)
- return .init(information: relevantInformation, sourceDocuments: documents.map(\.document))
+ return .init(
+ information: relevantInformation,
+ sourceDocuments: documents.map(\.document),
+ distance: documents.map(\.distance)
+ )
}
public func parseOutput(_ output: Output) -> String {
diff --git a/Tool/Sources/LangChain/DocumentLoader/WebLoader.swift b/Tool/Sources/LangChain/DocumentLoader/WebLoader.swift
index 494f91e2..c484ab4a 100644
--- a/Tool/Sources/LangChain/DocumentLoader/WebLoader.swift
+++ b/Tool/Sources/LangChain/DocumentLoader/WebLoader.swift
@@ -36,9 +36,6 @@ public struct WebLoader: DocumentLoader {
for url in urls {
let strategy: LoadWebPageMainContentStrategy = {
switch url {
- case let url
- where url.absoluteString.contains("developer.apple.com/documentation"):
- return Developer_Apple_Documentation_LoadContentStrategy()
default:
return DefaultLoadContentStrategy()
}
@@ -210,30 +207,5 @@ extension WebLoader {
return true
}
}
-
- /// https://developer.apple.com/documentation
- struct Developer_Apple_Documentation_LoadContentStrategy: LoadWebPageMainContentStrategy {
- func load(
- _ document: SwiftSoup.Document,
- metadata: Document.Metadata
- ) throws -> [Document] {
- if let mainContent = try? {
- if let main = text(inFirstTag: "main", from: document) { return main }
- let body = try document.body()?.text()
- return body
- }() {
- return [.init(pageContent: mainContent, metadata: metadata)]
- }
- return []
- }
-
- func validate(_ document: SwiftSoup.Document) -> Bool {
- do {
- return !(try document.getElementsByTag("main").isEmpty())
- } catch {
- return false
- }
- }
- }
}
diff --git a/Tool/Sources/LangChain/DocumentTransformer/TextSplitter.swift b/Tool/Sources/LangChain/DocumentTransformer/TextSplitter.swift
index 4e467106..5880616c 100644
--- a/Tool/Sources/LangChain/DocumentTransformer/TextSplitter.swift
+++ b/Tool/Sources/LangChain/DocumentTransformer/TextSplitter.swift
@@ -26,6 +26,9 @@ public extension TextSplitter {
for (text, metadata) in zip(texts, metadata) {
let chunks = try await split(text: text)
for chunk in chunks {
+ var metadata = metadata
+ metadata["startUTF16Offset"] = .number(Double(chunk.startUTF16Offset))
+ metadata["endUTF16Offset"] = .number(Double(chunk.endUTF16Offset))
let document = Document(pageContent: chunk.text, metadata: metadata)
documents.append(document)
}
@@ -48,6 +51,41 @@ public extension TextSplitter {
func transformDocuments(_ documents: [Document]) async throws -> [Document] {
return try await splitDocuments(documents)
}
+
+ func joinDocuments(_ documents: [Document]) -> Document {
+ let textChunks: [TextChunk] = documents.compactMap { document in
+ func extract(_ key: String) -> Int? {
+ if case let .number(d) = document.metadata[key] {
+ return Int(d)
+ }
+ return nil
+ }
+ guard let start = extract("startUTF16Offset"),
+ let end = extract("endUTF16Offset")
+ else { return nil }
+ return TextChunk(
+ text: document.pageContent,
+ startUTF16Offset: start,
+ endUTF16Offset: end
+ )
+ }.sorted(by: { $0.startUTF16Offset < $1.startUTF16Offset })
+ var sumChunk: TextChunk?
+ for chunk in textChunks {
+ if let current = sumChunk {
+ if let merged = current.merged(with: chunk, force: true) {
+ sumChunk = merged
+ }
+ } else {
+ sumChunk = chunk
+ }
+ }
+ let pageContent = sumChunk?.text ?? ""
+ var metadata = documents.first?.metadata ?? [String: JSONValue]()
+ metadata["startUTF16Offset"] = nil
+ metadata["endUTF16Offset"] = nil
+
+ return Document(pageContent: pageContent, metadata: metadata)
+ }
}
public struct TextChunk: Equatable {
@@ -83,14 +121,14 @@ public extension TextSplitter {
let text = (a + b).map(\.text).joined()
var l = Int.max
var u = 0
-
+
for chunk in a + b {
l = min(l, chunk.startUTF16Offset)
u = max(u, chunk.endUTF16Offset)
}
-
+
guard l < u else { return nil }
-
+
return .init(text: text, startUTF16Offset: l, endUTF16Offset: u)
}
diff --git a/Tool/Sources/LangChain/Embedding/OpenAIEmbedding.swift b/Tool/Sources/LangChain/Embedding/OpenAIEmbedding.swift
index cdcb2a7d..84cc6b66 100644
--- a/Tool/Sources/LangChain/Embedding/OpenAIEmbedding.swift
+++ b/Tool/Sources/LangChain/Embedding/OpenAIEmbedding.swift
@@ -45,33 +45,12 @@ extension OpenAIEmbedding {
func getEmbeddings(
documents: [Document]
) async throws -> [EmbeddedDocument] {
- try await withThrowingTaskGroup(
- of: (document: Document, embeddings: [Float]).self
- ) { group in
- for document in documents {
- group.addTask {
- var retryCount = 6
- var previousError: Error?
- while retryCount > 0 {
- do {
- let embeddings = try await service.embed(text: document.pageContent)
- .data
- .map(\.embedding).first ?? []
- return (document, embeddings)
- } catch {
- retryCount -= 1
- previousError = error
- }
- }
- throw previousError ?? CancellationError()
- }
- }
- var all = [EmbeddedDocument]()
- for try await result in group {
- all.append(.init(document: result.document, embeddings: result.embeddings))
+ try await service.embed(text: documents.map(\.pageContent)).data
+ .compactMap {
+ let index = $0.index
+ guard index >= 0, index < documents.endIndex else { return nil }
+ return EmbeddedDocument(document: documents[index], embeddings: $0.embedding)
}
- return all
- }
}
/// OpenAI's embedding API doesn't support embedding inputs longer than the max token.
@@ -112,27 +91,27 @@ extension OpenAIEmbedding {
do {
if text.chunkedTokens.count <= 1 {
// if possible, we should just let OpenAI do the tokenization.
- return (
+ return try (
text.document,
- try await service.embed(text: text.document.pageContent)
+ await service.embed(text: text.document.pageContent)
.data
.map(\.embedding)
)
}
if shouldAverageLongEmbeddings {
- return (
+ return try (
text.document,
- try await service.embed(tokens: text.chunkedTokens)
+ await service.embed(tokens: text.chunkedTokens)
.data
.map(\.embedding)
)
}
// if `shouldAverageLongEmbeddings` is false,
// we only embed the first chunk to save some money.
- return (
+ return try (
text.document,
- try await service.embed(tokens: [text.chunkedTokens.first ?? []])
+ await service.embed(tokens: [text.chunkedTokens.first ?? []])
.data
.map(\.embedding)
)
diff --git a/Tool/Sources/LangChain/VectorStore/TemporaryUSearch.swift b/Tool/Sources/LangChain/VectorStore/TemporaryUSearch.swift
index e0505bf8..69ac8106 100644
--- a/Tool/Sources/LangChain/VectorStore/TemporaryUSearch.swift
+++ b/Tool/Sources/LangChain/VectorStore/TemporaryUSearch.swift
@@ -18,7 +18,7 @@ public actor TemporaryUSearch: VectorStore {
let index: USearchIndex
var documents: [USearchLabel: LabeledDocument] = [:]
- public init(identifier: String, dimensions: Int = 1536 /* text-embedding-ada-002 */ ) {
+ public init(identifier: String, dimensions: Int) {
self.identifier = calculateMD5Hash(identifier)
index = .init(
metric: .IP,
@@ -29,8 +29,8 @@ public actor TemporaryUSearch: VectorStore {
}
/// Load a USearch index if found.
- public static func load(identifier: String) async -> TemporaryUSearch? {
- let it = TemporaryUSearch(identifier: identifier)
+ public static func load(identifier: String, dimensions: Int) async -> TemporaryUSearch? {
+ let it = TemporaryUSearch(identifier: identifier, dimensions: dimensions)
do {
try await it.load()
return it
@@ -40,8 +40,8 @@ public actor TemporaryUSearch: VectorStore {
}
/// Create a readonly USearch instance if the index is found.
- public static func view(identifier: String) async -> TemporaryUSearch? {
- let it = TemporaryUSearch(identifier: identifier)
+ public static func view(identifier: String, dimensions: Int) async -> TemporaryUSearch? {
+ let it = TemporaryUSearch(identifier: identifier, dimensions: dimensions)
do {
try await it.view()
return it
diff --git a/Tool/Sources/Logger/Logger.swift b/Tool/Sources/Logger/Logger.swift
index 390e54d5..58d280f0 100644
--- a/Tool/Sources/Logger/Logger.swift
+++ b/Tool/Sources/Logger/Logger.swift
@@ -23,6 +23,7 @@ public final class Logger {
public static let license = Logger(category: "License")
public static let `extension` = Logger(category: "Extension")
public static let communicationBridge = Logger(category: "CommunicationBridge")
+ public static let chatProxy = Logger(category: "ChatProxy")
public static let debug = Logger(category: "Debug")
#if DEBUG
/// Use a temp logger to log something temporary. I won't be available in release builds.
@@ -52,7 +53,11 @@ public final class Logger {
osLogType = .error
}
+ #if DEBUG
+ os_log("%{public}@", log: osLog, type: osLogType, "\(file):\(line) \(function)\n\n\(message)" as CVarArg)
+ #else
os_log("%{public}@", log: osLog, type: osLogType, message as CVarArg)
+ #endif
}
public func debug(
diff --git a/Tool/Sources/ModificationBasic/ExplanationThenCodeStreamParser.swift b/Tool/Sources/ModificationBasic/ExplanationThenCodeStreamParser.swift
new file mode 100644
index 00000000..bb3a71e9
--- /dev/null
+++ b/Tool/Sources/ModificationBasic/ExplanationThenCodeStreamParser.swift
@@ -0,0 +1,269 @@
+import Foundation
+
+/// Parse a stream that contains explanation followed by a code block.
+public actor ExplanationThenCodeStreamParser {
+ enum State {
+ case explanation
+ case code
+ case codeOpening
+ case codeClosing
+ }
+
+ public enum Fragment: Sendable {
+ case explanation(String)
+ case code(String)
+ }
+
+ struct Buffer {
+ var content: String = ""
+ }
+
+ var _buffer: Buffer = .init()
+ var isAtBeginning = true
+ var buffer: String { _buffer.content }
+ var state: State = .explanation
+ let fullCodeDelimiter = "```"
+
+ public init() {}
+
+ private func appendBuffer(_ character: Character) {
+ _buffer.content.append(character)
+ }
+
+ private func appendBuffer(_ content: String) {
+ _buffer.content += content
+ }
+
+ private func resetBuffer() {
+ _buffer.content = ""
+ }
+
+ func flushBuffer() -> String? {
+ if buffer.isEmpty { return nil }
+ guard let targetIndex = _buffer.content.lastIndex(where: { $0 != "`" && !$0.isNewline })
+ else { return nil }
+ let prefix = _buffer.content[...targetIndex]
+ if prefix.isEmpty { return nil }
+ let nextIndex = _buffer.content.index(
+ targetIndex,
+ offsetBy: 1,
+ limitedBy: _buffer.content.endIndex
+ ) ?? _buffer.content.endIndex
+
+ if nextIndex == _buffer.content.endIndex {
+ _buffer.content = ""
+ } else {
+ _buffer.content = String(
+ _buffer.content[nextIndex...]
+ )
+ }
+
+ // If we flushed something, we are no longer at the beginning
+ isAtBeginning = false
+ return String(prefix)
+ }
+
+ func flushBufferIfNeeded(into results: inout [Fragment]) {
+ switch state {
+ case .explanation:
+ if let flushed = flushBuffer() {
+ results.append(.explanation(flushed))
+ }
+ case .code:
+ if let flushed = flushBuffer() {
+ results.append(.code(flushed))
+ }
+ case .codeOpening, .codeClosing:
+ break
+ }
+ }
+
+ public func yield(_ fragment: String) -> [Fragment] {
+ var results: [Fragment] = []
+
+ func flushBuffer() {
+ flushBufferIfNeeded(into: &results)
+ }
+
+ for character in fragment {
+ switch state {
+ case .explanation:
+ func forceFlush() {
+ if !buffer.isEmpty {
+ isAtBeginning = false
+ results.append(.explanation(buffer))
+ resetBuffer()
+ }
+ }
+
+ switch character {
+ case "`":
+ if let last = buffer.last, last == "`" || last.isNewline {
+ flushBuffer()
+ // if we are seeing the pattern of "\n`" or "``"
+ // that mean we may be hitting a code delimiter
+ appendBuffer(character)
+ let shouldOpenCodeBlock: Bool = {
+ guard buffer.hasSuffix(fullCodeDelimiter)
+ else { return false }
+ if isAtBeginning { return true }
+ let temp = String(buffer.dropLast(fullCodeDelimiter.count))
+ if let last = temp.last, last.isNewline {
+ return true
+ }
+ return false
+ }()
+ // if we meet a code delimiter while in explanation state,
+ // it means we are opening a code block
+ if shouldOpenCodeBlock {
+ results.append(.explanation(
+ String(buffer.dropLast(fullCodeDelimiter.count))
+ .trimmingTrailingCharacters(in: .whitespacesAndNewlines)
+ ))
+ resetBuffer()
+ state = .codeOpening
+ }
+ } else {
+ // Otherwise, the backtick is probably part of the explanation.
+ forceFlush()
+ appendBuffer(character)
+ }
+ case let char where char.isNewline:
+ // we keep the trailing new lines in case they are right
+ // ahead of the code block that should be ignored.
+ if let last = buffer.last, last.isNewline {
+ flushBuffer()
+ appendBuffer(character)
+ } else {
+ forceFlush()
+ appendBuffer(character)
+ }
+ default:
+ appendBuffer(character)
+ }
+ case .code:
+ func forceFlush() {
+ if !buffer.isEmpty {
+ isAtBeginning = false
+ results.append(.code(buffer))
+ resetBuffer()
+ }
+ }
+
+ switch character {
+ case "`":
+ if let last = buffer.last, last == "`" || last.isNewline {
+ flushBuffer()
+ // if we are seeing the pattern of "\n`" or "``"
+ // that mean we may be hitting a code delimiter
+ appendBuffer(character)
+ let possibleClosingDelimiter: String? = {
+ guard buffer.hasSuffix(fullCodeDelimiter) else { return nil }
+ let temp = String(buffer.dropLast(fullCodeDelimiter.count))
+ if let last = temp.last, last.isNewline {
+ return "\(last)\(fullCodeDelimiter)"
+ }
+ return nil
+ }()
+ // if we meet a code delimiter while in code state,
+ // // it means we are closing the code block
+ if let possibleClosingDelimiter {
+ results.append(.code(
+ String(buffer.dropLast(possibleClosingDelimiter.count))
+ ))
+ resetBuffer()
+ appendBuffer(possibleClosingDelimiter)
+ state = .codeClosing
+ }
+ } else {
+ // Otherwise, the backtick is probably part of the code.
+ forceFlush()
+ appendBuffer(character)
+ }
+
+ case let char where char.isNewline:
+ if let last = buffer.last, last.isNewline {
+ flushBuffer()
+ appendBuffer(character)
+ } else {
+ forceFlush()
+ appendBuffer(character)
+ }
+ default:
+ appendBuffer(character)
+ }
+ case .codeOpening:
+ // skip the code block fence
+ if character.isNewline {
+ state = .code
+ }
+ case .codeClosing:
+ appendBuffer(character)
+ switch character {
+ case "`":
+ let possibleClosingDelimiter: String? = {
+ guard buffer.hasSuffix(fullCodeDelimiter) else { return nil }
+ let temp = String(buffer.dropLast(fullCodeDelimiter.count))
+ if let last = temp.last, last.isNewline {
+ return "\(last)\(fullCodeDelimiter)"
+ }
+ return nil
+ }()
+ // if we meet another code delimiter while in codeClosing state,
+ // it means the previous code delimiter was part of the code
+ if let possibleClosingDelimiter {
+ results.append(.code(
+ String(buffer.dropLast(possibleClosingDelimiter.count))
+ ))
+ resetBuffer()
+ appendBuffer(possibleClosingDelimiter)
+ }
+ default:
+ break
+ }
+ }
+ }
+
+ flushBuffer()
+
+ return results
+ }
+
+ public func finish() -> [Fragment] {
+ guard !buffer.isEmpty else { return [] }
+
+ var results: [Fragment] = []
+ switch state {
+ case .explanation:
+ results.append(
+ .explanation(buffer.trimmingTrailingCharacters(in: .whitespacesAndNewlines))
+ )
+ case .code:
+ results.append(.code(buffer))
+ case .codeClosing:
+ break
+ case .codeOpening:
+ break
+ }
+ resetBuffer()
+
+ return results
+ }
+}
+
+extension String {
+ func trimmingTrailingCharacters(in characterSet: CharacterSet) -> String {
+ guard !isEmpty else {
+ return ""
+ }
+ var unicodeScalars = unicodeScalars
+ while let scalar = unicodeScalars.last {
+ if !characterSet.contains(scalar) {
+ return String(unicodeScalars)
+ }
+ unicodeScalars.removeLast()
+ }
+ return ""
+ }
+}
+
diff --git a/Tool/Sources/ModificationBasic/ModificationAgent.swift b/Tool/Sources/ModificationBasic/ModificationAgent.swift
new file mode 100644
index 00000000..a29224af
--- /dev/null
+++ b/Tool/Sources/ModificationBasic/ModificationAgent.swift
@@ -0,0 +1,117 @@
+import ChatBasic
+import ComposableArchitecture
+import Foundation
+import SuggestionBasic
+
+public enum ModificationAgentResponse {
+ case code(String)
+ case explanation(String)
+}
+
+public struct ModificationAgentRequest {
+ public var code: String
+ public var requirement: String
+ public var source: ModificationSource
+ public var isDetached: Bool
+ public var extraSystemPrompt: String?
+ public var range: CursorRange
+ public var references: [ChatMessage.Reference]
+ public var topics: [ChatMessage.Reference]
+
+ public struct ModificationSource: Equatable {
+ public var language: CodeLanguage
+ public var documentURL: URL
+ public var projectRootURL: URL
+ public var content: String
+ public var lines: [String]
+
+ public init(
+ language: CodeLanguage,
+ documentURL: URL,
+ projectRootURL: URL,
+ content: String,
+ lines: [String]
+ ) {
+ self.language = language
+ self.documentURL = documentURL
+ self.projectRootURL = projectRootURL
+ self.content = content
+ self.lines = lines
+ }
+ }
+
+ public init(
+ code: String,
+ requirement: String,
+ source: ModificationSource,
+ isDetached: Bool,
+ extraSystemPrompt: String? = nil,
+ range: CursorRange,
+ references: [ChatMessage.Reference],
+ topics: [ChatMessage.Reference]
+ ) {
+ self.code = code
+ self.requirement = requirement
+ self.source = source
+ self.isDetached = isDetached
+ self.extraSystemPrompt = extraSystemPrompt
+ self.range = range
+ self.references = references
+ self.topics = topics
+ }
+}
+
+public protocol ModificationAgent {
+ typealias Request = ModificationAgentRequest
+ typealias Response = ModificationAgentResponse
+
+ func send(_ request: Request) -> AsyncThrowingStream
+}
+
+public struct ModificationSnippet: Equatable, Identifiable {
+ public let id = UUID()
+ public var startLineIndex: Int
+ public var originalCode: String
+ public var modifiedCode: String
+ public var description: String
+ public var error: String?
+ public var attachedRange: CursorRange
+
+ public init(
+ startLineIndex: Int,
+ originalCode: String,
+ modifiedCode: String,
+ description: String,
+ error: String?,
+ attachedRange: CursorRange
+ ) {
+ self.startLineIndex = startLineIndex
+ self.originalCode = originalCode
+ self.modifiedCode = modifiedCode
+ self.description = description
+ self.error = error
+ self.attachedRange = attachedRange
+ }
+}
+
+public enum ModificationAttachedTarget: Equatable {
+ case file(URL, projectURL: URL, code: String, lines: [String])
+ case dynamic
+}
+
+public struct ModificationHistoryNode {
+ public var snippets: IdentifiedArrayOf
+ public var instruction: NSAttributedString
+ public var references: [ChatMessage.Reference]
+
+ public init(
+ snippets: IdentifiedArrayOf,
+ instruction: NSAttributedString,
+ references: [ChatMessage.Reference]
+ ) {
+ self.snippets = snippets
+ self.instruction = instruction
+ self.references = references
+ }
+}
+
diff --git a/Tool/Sources/ModificationBasic/ModificationState.swift b/Tool/Sources/ModificationBasic/ModificationState.swift
new file mode 100644
index 00000000..51b7b28b
--- /dev/null
+++ b/Tool/Sources/ModificationBasic/ModificationState.swift
@@ -0,0 +1,87 @@
+import ChatBasic
+import Foundation
+import IdentifiedCollections
+import SuggestionBasic
+
+public struct ModificationState {
+ public typealias Source = ModificationAgentRequest.ModificationSource
+
+ public var source: Source
+ public var history: [ModificationHistoryNode] = []
+ public var snippets: IdentifiedArrayOf = []
+ public var isGenerating: Bool = false
+ public var extraSystemPrompt: String
+ public var isAttachedToTarget: Bool = true
+ public var status = [String]()
+ public var references: [ChatMessage.Reference] = []
+
+ public init(
+ source: Source,
+ history: [ModificationHistoryNode] = [],
+ snippets: IdentifiedArrayOf,
+ extraSystemPrompt: String,
+ isAttachedToTarget: Bool,
+ isGenerating: Bool = false,
+ status: [String] = [],
+ references: [ChatMessage.Reference] = []
+ ) {
+ self.history = history
+ self.snippets = snippets
+ self.isGenerating = isGenerating
+ self.isAttachedToTarget = isAttachedToTarget
+ self.extraSystemPrompt = extraSystemPrompt
+ self.source = source
+ self.status = status
+ self.references = references
+ }
+
+ public init(
+ source: Source,
+ originalCode: String,
+ attachedRange: CursorRange,
+ instruction: String,
+ extraSystemPrompt: String
+ ) {
+ self.init(
+ source: source,
+ snippets: [
+ .init(
+ startLineIndex: 0,
+ originalCode: originalCode,
+ modifiedCode: originalCode,
+ description: "",
+ error: nil,
+ attachedRange: attachedRange
+ ),
+ ],
+ extraSystemPrompt: extraSystemPrompt,
+ isAttachedToTarget: !attachedRange.isEmpty
+ )
+ }
+
+ public mutating func popHistory() -> NSAttributedString? {
+ if !history.isEmpty {
+ let last = history.removeLast()
+ references = last.references
+ snippets = last.snippets
+ let instruction = last.instruction
+ return instruction
+ }
+
+ return nil
+ }
+
+ public mutating func pushHistory(instruction: NSAttributedString) {
+ history.append(.init(snippets: snippets, instruction: instruction, references: references))
+ let oldSnippets = snippets
+ snippets = IdentifiedArrayOf()
+ for var snippet in oldSnippets {
+ snippet.originalCode = snippet.modifiedCode
+ snippet.modifiedCode = ""
+ snippet.description = ""
+ snippet.error = nil
+ snippets.append(snippet)
+ }
+ }
+}
+
diff --git a/Tool/Sources/OpenAIService/APIs/BuiltinExtensionChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/BuiltinExtensionChatCompletionsService.swift
index 2e5edeac..888c301f 100644
--- a/Tool/Sources/OpenAIService/APIs/BuiltinExtensionChatCompletionsService.swift
+++ b/Tool/Sources/OpenAIService/APIs/BuiltinExtensionChatCompletionsService.swift
@@ -51,7 +51,8 @@ extension BuiltinExtensionChatCompletionsService: ChatCompletionsAPI {
model: model,
message: .init(role: .assistant, content: content),
otherChoices: [],
- finishReason: ""
+ finishReason: "",
+ usage: nil
)
}
}
@@ -61,8 +62,8 @@ extension BuiltinExtensionChatCompletionsService: ChatCompletionsStreamAPI {
) async throws -> AsyncThrowingStream {
let service = try getChatService()
let (message, history) = extractMessageAndHistory(from: requestBody)
- guard let workspaceURL = await XcodeInspector.shared.safe.realtimeActiveWorkspaceURL,
- let projectURL = await XcodeInspector.shared.safe.realtimeActiveProjectURL
+ guard let workspaceURL = XcodeInspector.shared.realtimeActiveWorkspaceURL,
+ let projectURL = XcodeInspector.shared.realtimeActiveProjectURL
else { throw CancellationError() }
let stream = await service.sendMessage(
message,
@@ -114,7 +115,11 @@ extension BuiltinExtensionChatCompletionsService {
.joined(separator: "\n\n")
let history = Array(messages[0...lastIndexNotUserMessage])
return (message, history.map {
- .init(id: UUID().uuidString, role: $0.role.asChatMessageRole, content: $0.content)
+ .init(
+ id: UUID().uuidString,
+ role: $0.role.asChatMessageRole,
+ content: $0.content
+ )
})
} else { // everything is user message
let message = messages.map { $0.content }.joined(separator: "\n\n")
diff --git a/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIBuilder.swift b/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIBuilder.swift
index f114b32d..8f8e3feb 100644
--- a/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIBuilder.swift
+++ b/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIBuilder.swift
@@ -62,6 +62,11 @@ struct DefaultChatCompletionsAPIBuilder: ChatCompletionsAPIBuilder {
endpoint: endpoint,
requestBody: requestBody
)
+ case .gitHubCopilot:
+ return GitHubCopilotChatCompletionsService(
+ model: model,
+ requestBody: requestBody
+ )
}
}
@@ -107,6 +112,11 @@ struct DefaultChatCompletionsAPIBuilder: ChatCompletionsAPIBuilder {
endpoint: endpoint,
requestBody: requestBody
)
+ case .gitHubCopilot:
+ return GitHubCopilotChatCompletionsService(
+ model: model,
+ requestBody: requestBody
+ )
}
}
}
@@ -121,3 +131,4 @@ extension DependencyValues {
set { self[ChatCompletionsAPIBuilderDependencyKey.self] = newValue }
}
}
+
diff --git a/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIDefinition.swift b/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIDefinition.swift
index 39235219..76325f6f 100644
--- a/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIDefinition.swift
+++ b/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIDefinition.swift
@@ -1,17 +1,17 @@
import AIModel
+import ChatBasic
import CodableWrappers
import Foundation
import Preferences
-import ChatBasic
-struct ChatCompletionsRequestBody: Codable, Equatable {
- struct Message: Codable, Equatable {
- enum Role: String, Codable, Equatable {
+struct ChatCompletionsRequestBody: Equatable {
+ struct Message: Equatable {
+ enum Role: String, Equatable {
case system
case user
case assistant
case tool
-
+
var asChatMessageRole: ChatMessage.Role {
switch self {
case .system:
@@ -25,6 +25,30 @@ struct ChatCompletionsRequestBody: Codable, Equatable {
}
}
}
+
+ struct Image: Equatable {
+ enum Format: String {
+ case png = "image/png"
+ case jpeg = "image/jpeg"
+ case gif = "image/gif"
+ }
+ var base64EncodeData: String
+ var format: Format
+
+ var dataURLString: String {
+ return "data:\(format.rawValue);base64,\(base64EncodeData)"
+ }
+ }
+
+ struct Audio: Equatable {
+ enum Format: String {
+ case wav
+ case mp3
+ }
+
+ var data: Data
+ var format: Format
+ }
/// The role of the message.
var role: Role
@@ -34,23 +58,29 @@ struct ChatCompletionsRequestBody: Codable, Equatable {
/// name of the function call, and include the result in `content`.
///
/// - important: It's required when the role is `function`.
- var name: String?
+ var name: String? = nil
/// Tool calls in an assistant message.
- var toolCalls: [MessageToolCall]?
+ var toolCalls: [MessageToolCall]? = nil
/// When we want to call a tool, we have to provide the id of the call.
///
/// - important: It's required when the role is `tool`.
- var toolCallId: String?
+ var toolCallId: String? = nil
+ /// Images to include in the message.
+ var images: [Image] = []
+ /// Audios to include in the message.
+ var audios: [Audio] = []
+ /// Cache the message if possible.
+ var cacheIfPossible: Bool = false
}
- struct MessageFunctionCall: Codable, Equatable {
+ struct MessageFunctionCall: Equatable {
/// The name of the
var name: String
/// A JSON string.
var arguments: String?
}
- struct MessageToolCall: Codable, Equatable {
+ struct MessageToolCall: Equatable {
/// The id of the tool call.
var id: String
/// The type of the tool.
@@ -59,7 +89,7 @@ struct ChatCompletionsRequestBody: Codable, Equatable {
var function: MessageFunctionCall
}
- struct Tool: Codable, Equatable {
+ struct Tool: Equatable {
var type: String = "function"
var function: ChatGPTFunctionSchema
}
@@ -141,6 +171,23 @@ protocol ChatCompletionsStreamAPI {
func callAsFunction() async throws -> AsyncThrowingStream
}
+extension ChatCompletionsStreamAPI {
+ static func setupExtraHeaderFields(
+ _ request: inout URLRequest,
+ model: ChatModel,
+ apiKey: String
+ ) async {
+ let parser = HeaderValueParser()
+ for field in model.info.customHeaderInfo.headers where !field.key.isEmpty {
+ let value = await parser.parse(
+ field.value,
+ context: .init(modelName: model.info.modelName, apiKey: apiKey)
+ )
+ request.setValue(value, forHTTPHeaderField: field.key)
+ }
+ }
+}
+
extension AsyncSequence {
func toStream() -> AsyncThrowingStream {
AsyncThrowingStream { continuation in
@@ -178,14 +225,24 @@ struct ChatCompletionsStreamDataChunk {
var role: ChatCompletionsRequestBody.Message.Role?
var content: String?
+ var reasoningContent: String?
var toolCalls: [ToolCall]?
}
+ struct Usage: Codable, Equatable {
+ var promptTokens: Int?
+ var completionTokens: Int?
+
+ var cachedTokens: Int?
+ var otherUsage: [String: Int]
+ }
+
var id: String?
var object: String?
var model: String?
var message: Delta?
var finishReason: String?
+ var usage: Usage?
}
// MARK: - Non Stream API
@@ -194,8 +251,55 @@ protocol ChatCompletionsAPI {
func callAsFunction() async throws -> ChatCompletionResponseBody
}
-struct ChatCompletionResponseBody: Codable, Equatable {
- typealias Message = ChatCompletionsRequestBody.Message
+struct ChatCompletionResponseBody: Equatable {
+ struct Message: Equatable {
+ typealias Role = ChatCompletionsRequestBody.Message.Role
+ typealias MessageToolCall = ChatCompletionsRequestBody.MessageToolCall
+
+ /// The role of the message.
+ var role: Role
+ /// The content of the message.
+ var content: String?
+ /// The reasoning content of the message.
+ var reasoningContent: String?
+ /// When we want to reply to a function call with the result, we have to provide the
+ /// name of the function call, and include the result in `content`.
+ ///
+ /// - important: It's required when the role is `function`.
+ var name: String?
+ /// Tool calls in an assistant message.
+ var toolCalls: [MessageToolCall]?
+ /// When we want to call a tool, we have to provide the id of the call.
+ ///
+ /// - important: It's required when the role is `tool`.
+ var toolCallId: String?
+ }
+
+ struct Usage: Equatable {
+ var promptTokens: Int
+ var completionTokens: Int
+
+ var cachedTokens: Int
+ var otherUsage: [String: Int]
+
+ mutating func merge(with other: ChatCompletionsStreamDataChunk.Usage) {
+ promptTokens += other.promptTokens ?? 0
+ completionTokens += other.completionTokens ?? 0
+ cachedTokens += other.cachedTokens ?? 0
+ for (key, value) in other.otherUsage {
+ otherUsage[key, default: 0] += value
+ }
+ }
+
+ mutating func merge(with other: Self) {
+ promptTokens += other.promptTokens
+ completionTokens += other.completionTokens
+ cachedTokens += other.cachedTokens
+ for (key, value) in other.otherUsage {
+ otherUsage[key, default: 0] += value
+ }
+ }
+ }
var id: String?
var object: String
@@ -203,5 +307,6 @@ struct ChatCompletionResponseBody: Codable, Equatable {
var message: Message
var otherChoices: [Message]
var finishReason: String
+ var usage: Usage?
}
diff --git a/Tool/Sources/OpenAIService/APIs/ClaudeChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/ClaudeChatCompletionsService.swift
index 53c33410..223eab79 100644
--- a/Tool/Sources/OpenAIService/APIs/ClaudeChatCompletionsService.swift
+++ b/Tool/Sources/OpenAIService/APIs/ClaudeChatCompletionsService.swift
@@ -1,24 +1,32 @@
import AIModel
import AsyncAlgorithms
+import ChatBasic
import CodableWrappers
import Foundation
+import JoinJSON
import Logger
import Preferences
+#warning("Update the definitions")
/// https://docs.anthropic.com/claude/reference/messages_post
public actor ClaudeChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI {
+ /// https://docs.anthropic.com/en/docs/about-claude/models
public enum KnownModel: String, CaseIterable {
- case claude35Sonnet = "claude-3-5-sonnet-20240620"
- case claude3Opus = "claude-3-opus-20240229"
+ case claude37Sonnet = "claude-3-7-sonnet-latest"
+ case claude35Sonnet = "claude-3-5-sonnet-latest"
+ case claude35Haiku = "claude-3-5-haiku-latest"
+ case claude3Opus = "claude-3-opus-latest"
case claude3Sonnet = "claude-3-sonnet-20240229"
case claude3Haiku = "claude-3-haiku-20240307"
public var contextWindow: Int {
switch self {
case .claude35Sonnet: return 200_000
+ case .claude35Haiku: return 200_000
case .claude3Opus: return 200_000
case .claude3Sonnet: return 200_000
case .claude3Haiku: return 200_000
+ case .claude37Sonnet: return 200_000
}
}
}
@@ -33,11 +41,11 @@ public actor ClaudeChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet
var type: String
var errorDescription: String? {
- error?.message ?? "Unknown Error"
+ error?.message ?? error?.type ?? type
}
}
- enum MessageRole: String, Codable {
+ public enum MessageRole: String, Codable {
case user
case assistant
@@ -56,6 +64,7 @@ public actor ClaudeChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet
var content_block: ContentBlock?
var delta: Delta?
var error: APIError?
+ var usage: ResponseBody.Usage?
struct Message: Decodable {
var id: String
@@ -65,7 +74,7 @@ public actor ClaudeChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet
var model: String
var stop_reason: String?
var stop_sequence: String?
- var usage: Usage?
+ var usage: ResponseBody.Usage?
}
struct ContentBlock: Decodable {
@@ -74,16 +83,10 @@ public actor ClaudeChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet
}
struct Delta: Decodable {
- var type: String
+ var type: String?
var text: String?
var stop_reason: String?
var stop_sequence: String?
- var usage: Usage?
- }
-
- struct Usage: Decodable {
- var input_tokens: Int?
- var output_tokens: Int?
}
}
@@ -111,6 +114,8 @@ public actor ClaudeChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet
struct Usage: Codable, Equatable {
var input_tokens: Int?
var output_tokens: Int?
+ var cache_creation_input_tokens: Int?
+ var cache_read_input_tokens: Int?
}
var id: String?
@@ -123,33 +128,42 @@ public actor ClaudeChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet
var stop_sequence: String?
}
- struct RequestBody: Encodable, Equatable {
- struct MessageContent: Encodable, Equatable {
- enum MessageContentType: String, Encodable, Equatable {
+ public struct RequestBody: Codable, Equatable {
+ public struct CacheControl: Codable, Equatable, Sendable {
+ public enum CacheControlType: String, Codable, Equatable, Sendable {
+ case ephemeral
+ }
+
+ public var type: CacheControlType = .ephemeral
+ }
+
+ 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?
+ 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 {
@@ -169,13 +183,26 @@ public actor ClaudeChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet
}
}
- var model: String
- var system: String
- var messages: [Message]
- var temperature: Double?
- var stream: Bool?
- var stop_sequences: [String]?
- var max_tokens: Int
+ public struct SystemPrompt: Codable, Equatable {
+ public var type = "text"
+ public var text: String
+ public var cache_control: CacheControl?
+ }
+
+ public struct Tool: Codable, Equatable {
+ public var name: String
+ public var description: String
+ public var input_schema: JSONSchemaValue
+ }
+
+ 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
@@ -205,9 +232,12 @@ public actor ClaudeChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet
request.httpBody = try encoder.encode(requestBody)
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version")
+ request.setValue("prompt-caching-2024-07-31", forHTTPHeaderField: "anthropic-beta")
if !apiKey.isEmpty {
request.setValue(apiKey, forHTTPHeaderField: "x-api-key")
}
+ Self.setupCustomBody(&request, model: model)
+ await Self.setupExtraHeaderFields(&request, model: model, apiKey: apiKey)
let (result, response) = try await URLSession.shared.bytes(for: request)
guard let response = response as? HTTPURLResponse else {
@@ -243,7 +273,13 @@ public actor ClaudeChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet
StreamDataChunk.self,
from: line.data(using: .utf8) ?? Data()
)
+ if let error = chunk.error {
+ throw error
+ }
return .init(chunk: chunk, done: chunk.type == "message_stop")
+ } catch let error as APIError {
+ Logger.service.error(error.errorDescription ?? "Unknown Error")
+ throw error
} catch {
Logger.service.error("Error decoding stream data: \(error)")
return .init(chunk: nil, done: false)
@@ -261,9 +297,12 @@ public actor ClaudeChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet
request.httpBody = try encoder.encode(requestBody)
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version")
+ request.setValue("prompt-caching-2024-07-31", forHTTPHeaderField: "anthropic-beta")
if !apiKey.isEmpty {
request.setValue(apiKey, forHTTPHeaderField: "x-api-key")
}
+ Self.setupCustomBody(&request, model: model)
+ await Self.setupExtraHeaderFields(&request, model: model, apiKey: apiKey)
let (result, response) = try await URLSession.shared.data(for: request)
guard let response = response as? HTTPURLResponse else {
@@ -284,6 +323,15 @@ public actor ClaudeChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet
throw error
}
}
+
+ static func setupCustomBody(_ request: inout URLRequest, model: ChatModel) {
+ let join = JoinJSON()
+ let jsonBody = model.info.customBodyInfo.jsonBody
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ guard let data = request.httpBody, !jsonBody.isEmpty else { return }
+ let newBody = join.join(data, with: jsonBody)
+ request.httpBody = newBody
+ }
}
extension ClaudeChatCompletionsService.ResponseBody {
@@ -301,13 +349,26 @@ extension ClaudeChatCompletionsService.ResponseBody {
}
),
otherChoices: [],
- finishReason: stop_reason ?? ""
+ finishReason: stop_reason ?? "",
+ usage: .init(
+ promptTokens: usage.input_tokens ?? 0,
+ completionTokens: usage.output_tokens ?? 0,
+ cachedTokens: usage.cache_read_input_tokens ?? 0,
+ otherUsage: {
+ var otherUsage = [String: Int]()
+ if let cacheCreation = usage.cache_creation_input_tokens {
+ otherUsage["cache_creation_input_tokens"] = cacheCreation
+ }
+ return otherUsage
+ }()
+ )
)
}
}
extension ClaudeChatCompletionsService.StreamDataChunk {
func formalized() -> ChatCompletionsStreamDataChunk {
+ let usage = usage ?? message?.usage
return .init(
id: message?.id,
object: "chat.completions",
@@ -321,7 +382,19 @@ extension ClaudeChatCompletionsService.StreamDataChunk {
}
return nil
}(),
- finishReason: delta?.stop_reason
+ finishReason: delta?.stop_reason,
+ usage: .init(
+ promptTokens: usage?.input_tokens,
+ completionTokens: usage?.output_tokens,
+ cachedTokens: usage?.cache_read_input_tokens,
+ otherUsage: {
+ var otherUsage = [String: Int]()
+ if let cacheCreation = usage?.cache_creation_input_tokens {
+ otherUsage["cache_creation_input_tokens"] = cacheCreation
+ }
+ return otherUsage
+ }()
+ )
)
}
}
@@ -329,42 +402,173 @@ extension ClaudeChatCompletionsService.StreamDataChunk {
extension ClaudeChatCompletionsService.RequestBody {
init(_ body: ChatCompletionsRequestBody) {
model = body.model
+ let prefixChecks = [
+ "claude-3-5-sonnet", "claude-3-5-haiku", "claude-3-opus", "claude-3-haiku",
+ "claude-3.5-sonnet", "claude-3.5-haiku",
+ ]
+ let supportsPromptCache = if prefixChecks.contains(where: model.hasPrefix) {
+ true
+ } else {
+ false
+ }
- var systemPrompts = [String]()
+ var systemPrompts = [SystemPrompt]()
var nonSystemMessages = [Message]()
+ enum JoinType {
+ case joinMessage
+ case appendToList
+ case padMessageAndAppendToList
+ }
+
+ func checkJoinType(for message: ChatCompletionsRequestBody.Message) -> JoinType {
+ guard let last = nonSystemMessages.last else { return .appendToList }
+ let newMessageRole: ClaudeChatCompletionsService.MessageRole = message.role == .user
+ ? .user
+ : .assistant
+
+ if newMessageRole != last.role {
+ return .appendToList
+ }
+
+ if message.cacheIfPossible != last.content
+ .contains(where: { $0.cache_control != nil })
+ {
+ return .padMessageAndAppendToList
+ }
+
+ return .joinMessage
+ }
+
+ /// Claude only supports caching at most 4 messages.
+ var cacheControlMax = 4
+
+ func consumeCacheControl() -> Bool {
+ if cacheControlMax > 0 {
+ cacheControlMax -= 1
+ return true
+ }
+ return false
+ }
+
+ func convertMessageContent(
+ _ message: ChatCompletionsRequestBody.Message
+ ) -> [MessageContent] {
+ var content = [MessageContent]()
+
+ content.append(.init(type: .text, text: message.content, cache_control: {
+ if message.cacheIfPossible, supportsPromptCache, consumeCacheControl() {
+ return .init()
+ } else {
+ return nil
+ }
+ }()))
+ for image in message.images {
+ content.append(.init(type: .image, source: .init(
+ type: "base64",
+ media_type: image.format.rawValue,
+ data: image.base64EncodeData
+ )))
+ }
+
+ return content
+ }
+
+ func convertMessage(_ message: ChatCompletionsRequestBody.Message) -> Message {
+ let role: ClaudeChatCompletionsService.MessageRole = switch message.role {
+ case .system: .assistant
+ case .assistant, .tool: .assistant
+ case .user: .user
+ }
+
+ let content: [MessageContent] = convertMessageContent(message)
+
+ return .init(role: role, content: content)
+ }
+
for message in body.messages {
switch message.role {
case .system:
- systemPrompts.append(message.content)
+ systemPrompts.append(.init(text: message.content, cache_control: {
+ if message.cacheIfPossible, supportsPromptCache, consumeCacheControl() {
+ return .init()
+ } else {
+ return nil
+ }
+ }()))
case .tool, .assistant:
- if let last = nonSystemMessages.last, last.role == .assistant {
- nonSystemMessages[nonSystemMessages.endIndex - 1].appendText(message.content)
- } else {
- nonSystemMessages.append(.init(
- role: .assistant,
- content: [.init(type: .text, text: message.content)]
- ))
+ switch checkJoinType(for: message) {
+ case .appendToList:
+ nonSystemMessages.append(convertMessage(message))
+ case .padMessageAndAppendToList, .joinMessage:
+ nonSystemMessages[nonSystemMessages.endIndex - 1].content
+ .append(contentsOf: convertMessageContent(message))
}
case .user:
- if let last = nonSystemMessages.last, last.role == .user {
- nonSystemMessages[nonSystemMessages.endIndex - 1].appendText(message.content)
- } else {
- nonSystemMessages.append(.init(
- role: .user,
- content: [.init(type: .text, text: message.content)]
- ))
+ switch checkJoinType(for: message) {
+ case .appendToList:
+ nonSystemMessages.append(convertMessage(message))
+ case .padMessageAndAppendToList, .joinMessage:
+ nonSystemMessages[nonSystemMessages.endIndex - 1].content
+ .append(contentsOf: convertMessageContent(message))
}
}
}
messages = nonSystemMessages
- system = systemPrompts.joined(separator: "\n\n")
- .trimmingCharacters(in: .whitespacesAndNewlines)
+ system = systemPrompts
temperature = body.temperature
stream = body.stream
stop_sequences = body.stop
max_tokens = body.maxTokens ?? 4000
}
+
+ func formalized() -> ChatCompletionsRequestBody {
+ return .init(
+ model: model,
+ messages: system.map { system in
+ let convertedMessage = ChatCompletionsRequestBody.Message(
+ role: .system,
+ content: system.text,
+ cacheIfPossible: system.cache_control != nil
+ )
+ return convertedMessage
+ } + messages.map { message in
+ var convertedMessage = ChatCompletionsRequestBody.Message(
+ role: message.role == .user ? .user : .assistant,
+ content: "",
+ cacheIfPossible: message.content.contains(where: { $0.cache_control != nil })
+ )
+ for messageContent in message.content {
+ switch messageContent.type {
+ case .text:
+ if let text = messageContent.text {
+ convertedMessage.content += text
+ }
+ case .image:
+ if let source = messageContent.source {
+ convertedMessage.images.append(
+ .init(
+ base64EncodeData: source.data,
+ format: {
+ switch source.media_type {
+ case "image/png": return .png
+ case "image/gif": return .gif
+ default: return .jpeg
+ }
+ }()
+ )
+ )
+ }
+ }
+ }
+ return convertedMessage
+ },
+ temperature: temperature,
+ stream: stream,
+ stop: stop_sequences,
+ maxTokens: max_tokens
+ )
+ }
}
diff --git a/Tool/Sources/OpenAIService/APIs/EmbeddingAPIDefinitions.swift b/Tool/Sources/OpenAIService/APIs/EmbeddingAPIDefinitions.swift
index 0715e0f1..6a63ee7b 100644
--- a/Tool/Sources/OpenAIService/APIs/EmbeddingAPIDefinitions.swift
+++ b/Tool/Sources/OpenAIService/APIs/EmbeddingAPIDefinitions.swift
@@ -1,6 +1,7 @@
import AIModel
import Foundation
import Preferences
+import CodableWrappers
protocol EmbeddingAPI {
func embed(text: String) async throws -> EmbeddingResponse
@@ -8,21 +9,49 @@ protocol EmbeddingAPI {
func embed(tokens: [[Int]]) async throws -> EmbeddingResponse
}
+extension EmbeddingAPI {
+ static func setupExtraHeaderFields(
+ _ request: inout URLRequest,
+ model: EmbeddingModel,
+ apiKey: String
+ ) async {
+ let parser = HeaderValueParser()
+ for field in model.info.customHeaderInfo.headers where !field.key.isEmpty {
+ let value = await parser.parse(
+ field.value,
+ context: .init(modelName: model.info.modelName, apiKey: apiKey)
+ )
+ request.setValue(value, forHTTPHeaderField: field.key)
+ }
+ }
+}
+
public struct EmbeddingResponse: Decodable {
public struct Object: Decodable {
public var embedding: [Float]
public var index: Int
+ @FallbackDecoding
public var object: String
}
+ @FallbackDecoding
public var data: [Object]
+ @FallbackDecoding
public var model: String
public struct Usage: Decodable {
+ @FallbackDecoding
public var prompt_tokens: Int
+ @FallbackDecoding
public var total_tokens: Int
+
+ public struct Fallback: FallbackValueProvider {
+ public static var defaultValue: Usage { Usage(prompt_tokens: 0, total_tokens: 0) }
+ }
}
+ @FallbackDecoding
public var usage: Usage
}
+
diff --git a/Tool/Sources/OpenAIService/APIs/GitHubCopilotChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/GitHubCopilotChatCompletionsService.swift
new file mode 100644
index 00000000..2f200909
--- /dev/null
+++ b/Tool/Sources/OpenAIService/APIs/GitHubCopilotChatCompletionsService.swift
@@ -0,0 +1,112 @@
+import AIModel
+import AsyncAlgorithms
+import ChatBasic
+import Foundation
+import GitHubCopilotService
+import Logger
+import Preferences
+
+public enum AvailableGitHubCopilotModel: String, CaseIterable {
+ case claude35sonnet = "claude-3.5-sonnet"
+ case o1Mini = "o1-mini"
+ case o1 = "o1"
+ case gpt4Turbo = "gpt-4-turbo"
+ case gpt4oMini = "gpt-4o-mini"
+ case gpt4o = "gpt-4o"
+ case gpt4 = "gpt-4"
+ case gpt35Turbo = "gpt-3.5-turbo"
+
+ public var contextWindow: Int {
+ switch self {
+ case .claude35sonnet:
+ return 200_000
+ case .o1Mini:
+ return 128_000
+ case .o1:
+ return 128_000
+ case .gpt4Turbo:
+ return 128_000
+ case .gpt4oMini:
+ return 128_000
+ case .gpt4o:
+ return 128_000
+ case .gpt4:
+ return 32_768
+ case .gpt35Turbo:
+ return 16_384
+ }
+ }
+}
+
+/// Looks like it's used in many other popular repositories so maybe it's safe.
+actor GitHubCopilotChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI {
+
+ let chatModel: ChatModel
+ let requestBody: ChatCompletionsRequestBody
+
+ init(
+ model: ChatModel,
+ requestBody: ChatCompletionsRequestBody
+ ) {
+ var model = model
+ model.format = .openAICompatible
+ chatModel = model
+ self.requestBody = requestBody
+ }
+
+ func callAsFunction() async throws
+ -> AsyncThrowingStream
+ {
+ let service = try await buildService()
+ return try await service()
+ }
+
+ func callAsFunction() async throws -> ChatCompletionResponseBody {
+ let service = try await buildService()
+ return try await service()
+ }
+
+ private func buildService() async throws -> OpenAIChatCompletionsService {
+ let token = try await GitHubCopilotExtension.fetchToken()
+
+ guard let endpoint = URL(string: token.endpoints.api + "/chat/completions") else {
+ throw ChatGPTServiceError.endpointIncorrect
+ }
+
+ return OpenAIChatCompletionsService(
+ apiKey: token.token,
+ model: chatModel,
+ endpoint: endpoint,
+ requestBody: requestBody
+ ) { request in
+
+// POST /chat/completions HTTP/2
+// :authority: api.individual.githubcopilot.com
+// authorization: Bearer *
+// x-request-id: *
+// openai-organization: github-copilot
+// vscode-sessionid: *
+// vscode-machineid: *
+// editor-version: vscode/1.89.1
+// editor-plugin-version: Copilot for Xcode/0.35.5
+// copilot-language-server-version: 1.236.0
+// x-github-api-version: 2023-07-07
+// openai-intent: conversation-panel
+// content-type: application/json
+// user-agent: GithubCopilot/1.236.0
+// content-length: 9061
+// accept: */*
+// accept-encoding: gzip,deflate,br
+
+ request.setValue(
+ "Copilot for Xcode/\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown")",
+ forHTTPHeaderField: "Editor-Version"
+ )
+ request.setValue("Bearer \(token.token)", forHTTPHeaderField: "Authorization")
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.setValue("vscode-chat", forHTTPHeaderField: "Copilot-Integration-Id")
+ request.setValue("2023-07-07", forHTTPHeaderField: "X-Github-Api-Version")
+ }
+ }
+}
+
diff --git a/Tool/Sources/OpenAIService/APIs/GitHubCopilotEmbeddingService.swift b/Tool/Sources/OpenAIService/APIs/GitHubCopilotEmbeddingService.swift
new file mode 100644
index 00000000..627694a4
--- /dev/null
+++ b/Tool/Sources/OpenAIService/APIs/GitHubCopilotEmbeddingService.swift
@@ -0,0 +1,72 @@
+import AIModel
+import AsyncAlgorithms
+import ChatBasic
+import Foundation
+import GitHubCopilotService
+import Logger
+import Preferences
+
+/// Looks like it's used in many other popular repositories so maybe it's safe.
+actor GitHubCopilotEmbeddingService: EmbeddingAPI {
+ let chatModel: EmbeddingModel
+
+ init(model: EmbeddingModel) {
+ var model = model
+ model.format = .openAICompatible
+ chatModel = model
+ }
+
+ func embed(text: String) async throws -> EmbeddingResponse {
+ let service = try await buildService()
+ return try await service.embed(text: text)
+ }
+
+ func embed(texts: [String]) async throws -> EmbeddingResponse {
+ let service = try await buildService()
+ return try await service.embed(texts: texts)
+ }
+
+ func embed(tokens: [[Int]]) async throws -> EmbeddingResponse {
+ let service = try await buildService()
+ return try await service.embed(tokens: tokens)
+ }
+
+ private func buildService() async throws -> OpenAIEmbeddingService {
+ let token = try await GitHubCopilotExtension.fetchToken()
+
+ return OpenAIEmbeddingService(
+ apiKey: token.token,
+ model: chatModel,
+ endpoint: token.endpoints.api + "/embeddings"
+ ) { request in
+
+// POST /chat/completions HTTP/2
+// :authority: api.individual.githubcopilot.com
+// authorization: Bearer *
+// x-request-id: *
+// openai-organization: github-copilot
+// vscode-sessionid: *
+// vscode-machineid: *
+// editor-version: vscode/1.89.1
+// editor-plugin-version: Copilot for Xcode/0.35.5
+// copilot-language-server-version: 1.236.0
+// x-github-api-version: 2023-07-07
+// openai-intent: conversation-panel
+// content-type: application/json
+// user-agent: GithubCopilot/1.236.0
+// content-length: 9061
+// accept: */*
+// accept-encoding: gzip,deflate,br
+
+ request.setValue(
+ "Copilot for Xcode/\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown")",
+ forHTTPHeaderField: "Editor-Version"
+ )
+ request.setValue("Bearer \(token.token)", forHTTPHeaderField: "Authorization")
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.setValue("vscode-chat", forHTTPHeaderField: "Copilot-Integration-Id")
+ request.setValue("2023-07-07", forHTTPHeaderField: "X-Github-Api-Version")
+ }
+ }
+}
+
diff --git a/Tool/Sources/OpenAIService/APIs/GoogleAIChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/GoogleAIChatCompletionsService.swift
index e81609f9..6e4384c5 100644
--- a/Tool/Sources/OpenAIService/APIs/GoogleAIChatCompletionsService.swift
+++ b/Tool/Sources/OpenAIService/APIs/GoogleAIChatCompletionsService.swift
@@ -227,7 +227,7 @@ extension ModelContent {
case .assistant:
if let toolCalls = message.toolCalls {
return toolCalls.map { call in
- return """
+ """
Function ID: \(call.id)
Call function: \(call.function.name)
Arguments: \(call.function.arguments ?? "{}")
@@ -277,7 +277,8 @@ extension GenerateContentResponse {
model: "",
message: message,
otherChoices: otherMessages,
- finishReason: candidates.first?.finishReason?.rawValue ?? ""
+ finishReason: candidates.first?.finishReason?.rawValue ?? "",
+ usage: nil
)
}
diff --git a/Tool/Sources/OpenAIService/APIs/OlamaChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/OlamaChatCompletionsService.swift
index e2ef4d5a..9ac6e0dd 100644
--- a/Tool/Sources/OpenAIService/APIs/OlamaChatCompletionsService.swift
+++ b/Tool/Sources/OpenAIService/APIs/OlamaChatCompletionsService.swift
@@ -59,6 +59,13 @@ extension OllamaChatCompletionsService: ChatCompletionsAPI {
let encoder = JSONEncoder()
request.httpBody = try encoder.encode(requestBody)
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+
+ if !apiKey.isEmpty {
+ request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
+ }
+
+ await Self.setupExtraHeaderFields(&request, model: model, apiKey: apiKey)
+
let (result, response) = try await URLSession.shared.data(for: request)
guard let response = response as? HTTPURLResponse else {
@@ -94,7 +101,8 @@ extension OllamaChatCompletionsService: ChatCompletionsAPI {
)
} ?? .init(role: .assistant, content: ""),
otherChoices: [],
- finishReason: ""
+ finishReason: "",
+ usage: nil
)
}
}
@@ -134,6 +142,13 @@ extension OllamaChatCompletionsService: ChatCompletionsStreamAPI {
let encoder = JSONEncoder()
request.httpBody = try encoder.encode(requestBody)
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+
+ if !apiKey.isEmpty {
+ request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
+ }
+
+ await Self.setupExtraHeaderFields(&request, model: model, apiKey: apiKey)
+
let (result, response) = try await URLSession.shared.bytes(for: request)
guard let response = response as? HTTPURLResponse else {
diff --git a/Tool/Sources/OpenAIService/APIs/OllamaEmbeddingService.swift b/Tool/Sources/OpenAIService/APIs/OllamaEmbeddingService.swift
index dfd170cc..1e0f2933 100644
--- a/Tool/Sources/OpenAIService/APIs/OllamaEmbeddingService.swift
+++ b/Tool/Sources/OpenAIService/APIs/OllamaEmbeddingService.swift
@@ -12,6 +12,7 @@ struct OllamaEmbeddingService: EmbeddingAPI {
var embedding: [Float]
}
+ let apiKey: String
let model: EmbeddingModel
let endpoint: String
@@ -25,6 +26,14 @@ struct OllamaEmbeddingService: EmbeddingAPI {
model: model.info.modelName
))
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+
+ if !apiKey.isEmpty {
+ request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
+ }
+
+ for field in model.info.customHeaderInfo.headers where !field.key.isEmpty {
+ request.setValue(field.value, forHTTPHeaderField: field.key)
+ }
let (result, response) = try await URLSession.shared.data(for: request)
guard let response = response as? HTTPURLResponse else {
diff --git a/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift
index 618c7720..93c5987d 100644
--- a/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift
+++ b/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift
@@ -2,11 +2,12 @@ import AIModel
import AsyncAlgorithms
import ChatBasic
import Foundation
+import JoinJSON
import Logger
import Preferences
/// https://platform.openai.com/docs/api-reference/chat/create
-actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI {
+public actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI {
struct CompletionAPIError: Error, Decodable, LocalizedError {
struct ErrorDetail: Decodable {
var message: String
@@ -54,12 +55,7 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
- do {
- error = try container.decode(ErrorDetail.self, forKey: .error)
- } catch {
- print(error)
- self.error = nil
- }
+ error = try container.decode(ErrorDetail.self, forKey: .error)
message = {
if let e = try? container.decode(MistralAIErrorMessage.self, forKey: .message) {
return CompletionAPIError.Message.mistralAI(e)
@@ -72,16 +68,18 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI
}
}
- enum MessageRole: String, Codable {
+ public enum MessageRole: String, Codable, Sendable {
case system
case user
case assistant
case function
case tool
+ case developer
var formalized: ChatCompletionsRequestBody.Message.Role {
switch self {
case .system: return .system
+ case .developer: return .system
case .user: return .user
case .assistant: return .assistant
case .function: return .tool
@@ -90,37 +88,84 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI
}
}
- struct StreamDataChunk: Codable {
- var id: String?
- var object: String?
- var model: String?
- var choices: [Choice]?
+ public struct StreamDataChunk: Codable, Sendable {
+ public var id: String?
+ public var provider: String?
+ public var object: String?
+ public var model: String?
+ public var choices: [Choice]?
+ public var usage: ResponseBody.Usage?
+ public var created: Int?
+
+ public struct Choice: Codable, Sendable {
+ public var delta: Delta?
+ public var index: Int?
+ public var finish_reason: String?
- struct Choice: Codable {
- var delta: Delta?
- var index: Int?
- var finish_reason: String?
+ public struct Delta: Codable, Sendable {
+ public var role: MessageRole?
+ public var content: String?
+ public var reasoning_content: String?
+ public var reasoning: String?
+ public var function_call: RequestBody.MessageFunctionCall?
+ public var tool_calls: [RequestBody.MessageToolCall]?
+
+ public init(
+ role: MessageRole? = nil,
+ content: String? = nil,
+ reasoning_content: String? = nil,
+ reasoning: String? = nil,
+ function_call: RequestBody.MessageFunctionCall? = nil,
+ tool_calls: [RequestBody.MessageToolCall]? = nil
+ ) {
+ self.role = role
+ self.content = content
+ self.reasoning_content = reasoning_content
+ self.reasoning = reasoning
+ self.function_call = function_call
+ self.tool_calls = tool_calls
+ }
+ }
- struct Delta: Codable {
- var role: MessageRole?
- var content: String?
- var function_call: RequestBody.MessageFunctionCall?
- var tool_calls: [RequestBody.MessageToolCall]?
+ public init(delta: Delta? = nil, index: Int? = nil, finish_reason: String? = nil) {
+ self.delta = delta
+ self.index = index
+ self.finish_reason = finish_reason
}
}
+
+ public init(
+ id: String? = nil,
+ provider: String? = nil,
+ object: String? = nil,
+ model: String? = nil,
+ choices: [Choice]? = nil,
+ usage: ResponseBody.Usage? = nil,
+ created: Int? = nil
+ ) {
+ self.id = id
+ self.provider = provider
+ self.object = object
+ self.model = model
+ self.choices = choices
+ self.usage = usage
+ self.created = created
+ }
}
- struct ResponseBody: Codable, Equatable {
- struct Message: Codable, Equatable {
+ public struct ResponseBody: Codable, Equatable {
+ public struct Message: Codable, Equatable, Sendable {
/// The role of the message.
- var role: MessageRole
+ public var role: MessageRole
/// The content of the message.
- var content: String?
+ public var content: String?
+ public var reasoning_content: String?
+ public var reasoning: String?
/// When we want to reply to a function call with the result, we have to provide the
/// name of the function call, and include the result in `content`.
///
/// - important: It's required when the role is `function`.
- var name: String?
+ public var name: String?
/// When the bot wants to call a function, it will reply with a function call in format:
/// ```json
/// {
@@ -128,105 +173,464 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI
/// "arguments": "{ \"location\": \"earth\" }"
/// }
/// ```
- var function_call: RequestBody.MessageFunctionCall?
+ public var function_call: RequestBody.MessageFunctionCall?
/// Tool calls in an assistant message.
- var tool_calls: [RequestBody.MessageToolCall]?
+ public var tool_calls: [RequestBody.MessageToolCall]?
+
+ public init(
+ role: MessageRole,
+ content: String? = nil,
+ reasoning_content: String? = nil,
+ reasoning: String? = nil,
+ name: String? = nil,
+ function_call: RequestBody.MessageFunctionCall? = nil,
+ tool_calls: [RequestBody.MessageToolCall]? = nil
+ ) {
+ self.role = role
+ self.content = content
+ self.reasoning_content = reasoning_content
+ self.reasoning = reasoning
+ self.name = name
+ self.function_call = function_call
+ self.tool_calls = tool_calls
+ }
}
- struct Choice: Codable, Equatable {
- var message: Message
- var index: Int?
- var finish_reason: String?
+ public struct Choice: Codable, Equatable, Sendable {
+ public var message: Message
+ public var index: Int?
+ public var finish_reason: String?
+
+ public init(message: Message, index: Int? = nil, finish_reason: String? = nil) {
+ self.message = message
+ self.index = index
+ self.finish_reason = finish_reason
+ }
}
- struct Usage: Codable, Equatable {
- var prompt_tokens: Int?
- var completion_tokens: Int?
- var total_tokens: Int?
+ public struct Usage: Codable, Equatable, Sendable {
+ public var prompt_tokens: Int?
+ public var completion_tokens: Int?
+ public var total_tokens: Int?
+ public var prompt_tokens_details: PromptTokensDetails?
+ public var completion_tokens_details: CompletionTokensDetails?
+
+ public struct PromptTokensDetails: Codable, Equatable, Sendable {
+ public var cached_tokens: Int?
+ public var audio_tokens: Int?
+
+ public init(cached_tokens: Int? = nil, audio_tokens: Int? = nil) {
+ self.cached_tokens = cached_tokens
+ self.audio_tokens = audio_tokens
+ }
+ }
+
+ public struct CompletionTokensDetails: Codable, Equatable, Sendable {
+ public var reasoning_tokens: Int?
+ public var audio_tokens: Int?
+
+ public init(reasoning_tokens: Int? = nil, audio_tokens: Int? = nil) {
+ self.reasoning_tokens = reasoning_tokens
+ self.audio_tokens = audio_tokens
+ }
+ }
+
+ public init(
+ prompt_tokens: Int? = nil,
+ completion_tokens: Int? = nil,
+ total_tokens: Int? = nil,
+ prompt_tokens_details: PromptTokensDetails? = nil,
+ completion_tokens_details: CompletionTokensDetails? = nil
+ ) {
+ self.prompt_tokens = prompt_tokens
+ self.completion_tokens = completion_tokens
+ self.total_tokens = total_tokens
+ self.prompt_tokens_details = prompt_tokens_details
+ self.completion_tokens_details = completion_tokens_details
+ }
}
- var id: String?
- var object: String
- var model: String
- var usage: Usage
- var choices: [Choice]
+ public var id: String?
+ public var object: String
+ public var model: String
+ public var usage: Usage
+ public var choices: [Choice]
+
+ public init(
+ id: String? = nil,
+ object: String,
+ model: String,
+ usage: Usage,
+ choices: [Choice]
+ ) {
+ self.id = id
+ self.object = object
+ self.model = model
+ self.usage = usage
+ self.choices = choices
+ }
}
- struct RequestBody: Codable, Equatable {
- struct Message: Codable, Equatable {
+ 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 {
+ public var type = "text"
+ public var text: String
+ public var cache_control: ClaudeCacheControl?
+
+ public init(
+ type: String = "text",
+ text: String,
+ cache_control: ClaudeCacheControl? = nil
+ ) {
+ self.type = type
+ self.text = text
+ self.cache_control = cache_control
+ }
+ }
+
+ public struct ImageContentPart: Codable, Equatable {
+ public struct ImageURL: Codable, Equatable {
+ public var url: String
+ public var detail: String?
+
+ public init(url: String, detail: String? = nil) {
+ self.url = url
+ self.detail = detail
+ }
+ }
+
+ public var type = "image_url"
+ public var image_url: ImageURL
+
+ public init(type: String = "image_url", image_url: ImageURL) {
+ self.type = type
+ self.image_url = image_url
+ }
+ }
+
+ public struct AudioContentPart: Codable, Equatable {
+ public struct InputAudio: Codable, Equatable {
+ public var data: String
+ public var format: String
+
+ public init(data: String, format: String) {
+ self.data = data
+ self.format = format
+ }
+ }
+
+ public var type = "input_audio"
+ public var input_audio: InputAudio
+
+ public init(type: String = "input_audio", input_audio: InputAudio) {
+ self.type = type
+ self.input_audio = input_audio
+ }
+ }
+
+ public enum ContentPart: Codable, Equatable {
+ case text(TextContentPart)
+ case image(ImageContentPart)
+ case audio(AudioContentPart)
+
+ public func encode(to encoder: any Encoder) throws {
+ var container = encoder.singleValueContainer()
+ switch self {
+ case let .text(text):
+ try container.encode(text)
+ case let .image(image):
+ try container.encode(image)
+ case let .audio(audio):
+ try container.encode(audio)
+ }
+ }
+
+ public init(from decoder: any Decoder) throws {
+ let container = try decoder.singleValueContainer()
+ var errors: [Error] = []
+
+ do {
+ let text = try container.decode(String.self)
+ self = .text(.init(text: text))
+ return
+ } catch {
+ errors.append(error)
+ }
+
+ do {
+ let text = try container.decode(TextContentPart.self)
+ self = .text(text)
+ return
+ } catch {
+ errors.append(error)
+ }
+
+ do {
+ let image = try container.decode(ImageContentPart.self)
+ self = .image(image)
+ return
+ } catch {
+ errors.append(error)
+ }
+
+ do {
+ let audio = try container.decode(AudioContentPart.self)
+ self = .audio(audio)
+ return
+ } catch {
+ errors.append(error)
+ }
+
+ struct E: Error, LocalizedError {
+ let errors: [Error]
+
+ var errorDescription: String? {
+ "Failed to decode ContentPart: \(errors.map { $0.localizedDescription }.joined(separator: "; "))"
+ }
+ }
+ throw E(errors: errors)
+ }
+ }
+
+ case contentParts([ContentPart])
+ case text(String)
+
+ public func encode(to encoder: any Encoder) throws {
+ var container = encoder.singleValueContainer()
+ switch self {
+ case let .contentParts(parts):
+ try container.encode(parts)
+ case let .text(text):
+ try container.encode(text)
+ }
+ }
+
+ public init(from decoder: any Decoder) throws {
+ let container = try decoder.singleValueContainer()
+ var errors: [Error] = []
+
+ do {
+ let parts = try container.decode([ContentPart].self)
+ self = .contentParts(parts)
+ return
+ } catch {
+ errors.append(error)
+ }
+
+ do {
+ let text = try container.decode(String.self)
+ self = .text(text)
+ return
+ } catch {
+ errors.append(error)
+ }
+
+ do { // Null
+ _ = try container.decode([ContentPart]?.self)
+ self = .contentParts([])
+ return
+ } catch {
+ errors.append(error)
+ }
+
+ struct E: Error, LocalizedError {
+ let errors: [Error]
+
+ var errorDescription: String? {
+ "Failed to decode MessageContent: \(errors.map { $0.localizedDescription }.joined(separator: "; "))"
+ }
+ }
+ throw E(errors: errors)
+ }
+ }
+
/// The role of the message.
- var role: MessageRole
+ public var role: MessageRole
/// The content of the message.
- var content: String
+ public var content: MessageContent
/// When we want to reply to a function call with the result, we have to provide the
/// name of the function call, and include the result in `content`.
///
/// - important: It's required when the role is `function`.
- var name: String?
+ public var name: String?
/// Tool calls in an assistant message.
- var tool_calls: [MessageToolCall]?
+ public var tool_calls: [MessageToolCall]?
/// When we want to call a tool, we have to provide the id of the call.
///
/// - important: It's required when the role is `tool`.
- var tool_call_id: String?
+ public var tool_call_id: String?
/// When the bot wants to call a function, it will reply with a function call.
///
/// Deprecated.
- var function_call: MessageFunctionCall?
+ public var function_call: MessageFunctionCall?
+ #warning("TODO: when to use it?")
+ /// Cache control for GitHub Copilot models.
+ public var copilot_cache_control: GitHubCopilotCacheControl?
+
+ public init(
+ role: MessageRole,
+ content: MessageContent,
+ name: String? = nil,
+ tool_calls: [MessageToolCall]? = nil,
+ tool_call_id: String? = nil,
+ function_call: MessageFunctionCall? = nil,
+ copilot_cache_control: GitHubCopilotCacheControl? = nil
+ ) {
+ self.role = role
+ self.content = content
+ self.name = name
+ self.tool_calls = tool_calls
+ self.tool_call_id = tool_call_id
+ self.function_call = function_call
+ self.copilot_cache_control = copilot_cache_control
+ }
}
- struct MessageFunctionCall: Codable, Equatable {
+ public struct MessageFunctionCall: Codable, Equatable, Sendable {
/// The name of the
- var name: String?
+ public var name: String?
/// A JSON string.
- var arguments: String?
+ public var arguments: String?
+
+ public init(name: String? = nil, arguments: String? = nil) {
+ self.name = name
+ self.arguments = arguments
+ }
}
- struct MessageToolCall: Codable, Equatable {
+ public struct MessageToolCall: Codable, Equatable, Sendable {
/// When it's returned as a data chunk, use the index to identify the tool call.
- var index: Int?
+ public var index: Int?
/// The id of the tool call.
- var id: String?
+ public var id: String?
/// The type of the tool.
- var type: String?
+ public var type: String?
/// The function call.
- var function: MessageFunctionCall?
+ public var function: MessageFunctionCall?
+
+ public init(
+ index: Int? = nil,
+ id: String? = nil,
+ type: String? = nil,
+ function: MessageFunctionCall? = nil
+ ) {
+ self.index = index
+ self.id = id
+ self.type = type
+ self.function = function
+ }
}
- struct Tool: Codable, Equatable {
- var type: String = "function"
- var function: ChatGPTFunctionSchema
+ public struct Tool: Codable, Equatable, Sendable {
+ public var type: String = "function"
+ public var function: ChatGPTFunctionSchema
+
+ public init(type: String, function: ChatGPTFunctionSchema) {
+ self.type = type
+ self.function = function
+ }
}
- var model: String
- var messages: [Message]
- var temperature: Double?
- var stream: Bool?
- var stop: [String]?
- var max_tokens: Int?
- var tool_choice: FunctionCallStrategy?
- var tools: [Tool]?
+ public struct StreamOptions: Codable, Equatable, Sendable {
+ public var include_usage: Bool = true
+
+ public init(include_usage: Bool = true) {
+ self.include_usage = include_usage
+ }
+ }
+
+ public var model: String
+ public var messages: [Message]
+ public var temperature: Double?
+ public var stream: Bool?
+ public var stop: [String]?
+ public var max_completion_tokens: Int?
+ public var tool_choice: FunctionCallStrategy?
+ public var tools: [Tool]?
+ public var stream_options: StreamOptions?
+
+ public init(
+ model: String,
+ messages: [Message],
+ temperature: Double? = nil,
+ stream: Bool? = nil,
+ stop: [String]? = nil,
+ max_completion_tokens: Int? = nil,
+ tool_choice: FunctionCallStrategy? = nil,
+ tools: [Tool]? = nil,
+ stream_options: StreamOptions? = nil
+ ) {
+ self.model = model
+ self.messages = messages
+ self.temperature = temperature
+ self.stream = stream
+ self.stop = stop
+ self.max_completion_tokens = max_completion_tokens
+ self.tool_choice = tool_choice
+ self.tools = tools
+ self.stream_options = stream_options
+ }
}
var apiKey: String
var endpoint: URL
var requestBody: RequestBody
var model: ChatModel
+ let requestModifier: ((inout URLRequest) -> Void)?
init(
apiKey: String,
model: ChatModel,
endpoint: URL,
- requestBody: ChatCompletionsRequestBody
+ requestBody: ChatCompletionsRequestBody,
+ requestModifier: ((inout URLRequest) -> Void)? = nil
) {
self.apiKey = apiKey
self.endpoint = endpoint
self.requestBody = .init(
requestBody,
+ endpoint: endpoint,
enforceMessageOrder: model.info.openAICompatibleInfo.enforceMessageOrder,
- canUseTool: model.info.supportsFunctionCalling
+ supportsMultipartMessageContent: model.info.openAICompatibleInfo
+ .supportsMultipartMessageContent,
+ requiresBeginWithUserMessage: model.info.openAICompatibleInfo
+ .requiresBeginWithUserMessage,
+ canUseTool: model.info.supportsFunctionCalling,
+ supportsImage: model.info.supportsImage,
+ supportsAudio: model.info.supportsAudio,
+ supportsTemperature: {
+ guard model.format == .openAI else { return true }
+ if let chatGPTModel = ChatGPTModel(rawValue: model.info.modelName) {
+ return chatGPTModel.supportsTemperature
+ } else if model.info.modelName.hasPrefix("o") {
+ return false
+ }
+ return true
+ }(),
+ supportsSystemPrompt: {
+ guard model.format == .openAI else { return true }
+ if let chatGPTModel = ChatGPTModel(rawValue: model.info.modelName) {
+ return chatGPTModel.supportsSystemPrompt
+ } else if model.info.modelName.hasPrefix("o") {
+ return false
+ }
+ return true
+ }()
)
self.model = model
+ self.requestModifier = requestModifier
}
func callAsFunction() async throws
@@ -239,8 +643,12 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI
request.httpBody = try encoder.encode(requestBody)
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ Self.setupCustomBody(&request, model: model)
Self.setupAppInformation(&request)
Self.setupAPIKey(&request, model: model, apiKey: apiKey)
+ Self.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 {
@@ -258,7 +666,10 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI
}
let decoder = JSONDecoder()
let error = try? decoder.decode(CompletionAPIError.self, from: data)
- throw error ?? ChatGPTServiceError.responseInvalid
+ throw error ?? ChatGPTServiceError.otherError(
+ text +
+ "\n\nPlease check your model settings, some capabilities may not be supported by the model."
+ )
}
let stream = ResponseStream(result: result) {
@@ -295,7 +706,13 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI
model: "",
message: .init(role: .assistant, content: ""),
otherChoices: [],
- finishReason: ""
+ finishReason: "",
+ usage: .init(
+ promptTokens: 0,
+ completionTokens: 0,
+ cachedTokens: 0,
+ otherUsage: [:]
+ )
)
for try await chunk in stream {
if let id = chunk.id {
@@ -314,7 +731,11 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI
body.message.role = role
}
if let text = chunk.message?.content {
- body.message.content += text
+ let existed = body.message.content ?? ""
+ body.message.content = existed + text
+ }
+ if let usage = chunk.usage {
+ body.usage?.merge(with: usage)
}
}
return body
@@ -357,12 +778,14 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI
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:
@@ -372,6 +795,28 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI
}
}
}
+
+ static func setupGitHubCopilotVisionField(_ request: inout URLRequest, model: ChatModel) {
+ if model.info.supportsImage {
+ request.setValue("true", forHTTPHeaderField: "copilot-vision-request")
+ }
+ }
+
+ static func setupCustomBody(_ request: inout URLRequest, model: ChatModel) {
+ switch model.format {
+ case .openAI, .openAICompatible:
+ break
+ default:
+ return
+ }
+
+ let join = JoinJSON()
+ let jsonBody = model.info.customBodyInfo.jsonBody
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ guard let data = request.httpBody, !jsonBody.isEmpty else { return }
+ let newBody = join.join(data, with: jsonBody)
+ request.httpBody = newBody
+ }
}
extension OpenAIChatCompletionsService.ResponseBody {
@@ -383,6 +828,7 @@ extension OpenAIChatCompletionsService.ResponseBody {
.init(
role: message.role.formalized,
content: message.content ?? "",
+ reasoningContent: message.reasoning_content ?? message.reasoning ?? "",
toolCalls: {
if let toolCalls = message.tool_calls {
return toolCalls.map { toolCall in
@@ -421,13 +867,24 @@ extension OpenAIChatCompletionsService.ResponseBody {
otherMessages = []
}
+ let usage = ChatCompletionResponseBody.Usage(
+ promptTokens: usage.prompt_tokens ?? 0,
+ completionTokens: usage.completion_tokens ?? 0,
+ cachedTokens: usage.prompt_tokens_details?.cached_tokens ?? 0,
+ otherUsage: [
+ "audio_tokens": usage.completion_tokens_details?.audio_tokens ?? 0,
+ "reasoning_tokens": usage.completion_tokens_details?.reasoning_tokens ?? 0,
+ ]
+ )
+
return .init(
id: id,
object: object,
model: model,
message: message,
otherChoices: otherMessages,
- finishReason: choices.first?.finish_reason ?? ""
+ finishReason: choices.first?.finish_reason ?? "",
+ usage: usage
)
}
}
@@ -443,6 +900,8 @@ extension OpenAIChatCompletionsService.StreamDataChunk {
return .init(
role: choice.delta?.role?.formalized,
content: choice.delta?.content,
+ reasoningContent: choice.delta?.reasoning_content
+ ?? choice.delta?.reasoning,
toolCalls: {
if let toolCalls = choice.delta?.tool_calls {
return toolCalls.map {
@@ -478,30 +937,240 @@ extension OpenAIChatCompletionsService.StreamDataChunk {
}
return nil
}(),
- finishReason: choices?.first?.finish_reason
+ finishReason: choices?.first?.finish_reason,
+ usage: .init(
+ promptTokens: usage?.prompt_tokens,
+ completionTokens: usage?.completion_tokens,
+ cachedTokens: usage?.prompt_tokens_details?.cached_tokens,
+ otherUsage: {
+ var dict = [String: Int]()
+ if let audioTokens = usage?.completion_tokens_details?.audio_tokens {
+ dict["audio_tokens"] = audioTokens
+ }
+ if let reasoningTokens = usage?.completion_tokens_details?.reasoning_tokens {
+ dict["reasoning_tokens"] = reasoningTokens
+ }
+ return dict
+ }()
+ )
)
}
}
extension OpenAIChatCompletionsService.RequestBody {
- init(_ body: ChatCompletionsRequestBody, enforceMessageOrder: Bool, canUseTool: Bool) {
+ static func convertContentPart(
+ content: String,
+ images: [ChatCompletionsRequestBody.Message.Image],
+ audios: [ChatCompletionsRequestBody.Message.Audio]
+ ) -> [Message.MessageContent.ContentPart] {
+ var all = [Message.MessageContent.ContentPart]()
+ all.append(.text(.init(text: content)))
+
+ for image in images {
+ all.append(.image(.init(
+ image_url: .init(
+ url: image.dataURLString,
+ detail: nil
+ )
+ )))
+ }
+
+ for audio in audios {
+ all.append(.audio(.init(
+ input_audio: .init(
+ data: audio.data.base64EncodedString(),
+ format: audio.format.rawValue
+ )
+ )))
+ }
+
+ return all
+ }
+
+ static func convertContentPart(
+ _ part: ClaudeChatCompletionsService.RequestBody.MessageContent
+ ) -> Message.MessageContent.ContentPart? {
+ switch part.type {
+ case .text:
+ return .text(.init(text: part.text ?? "", cache_control: part.cache_control))
+ case .image:
+ let type = part.source?.type ?? "base64"
+ let base64Data = part.source?.data ?? ""
+ let mediaType = part.source?.media_type ?? "image/png"
+ return .image(.init(image_url: .init(url: "data:\(mediaType);\(type),\(base64Data)")))
+ }
+ }
+
+ static func joinMessageContent(
+ _ message: inout Message,
+ content: String,
+ images: [ChatCompletionsRequestBody.Message.Image],
+ audios: [ChatCompletionsRequestBody.Message.Audio],
+ supportsMultipartMessageContent: Bool
+ ) {
+ if supportsMultipartMessageContent {
+ switch message.role {
+ case .system, .developer, .assistant, .user:
+ let newParts = Self.convertContentPart(
+ content: content,
+ images: images,
+ audios: audios
+ )
+ if case let .contentParts(existingParts) = message.content {
+ message.content = .contentParts(existingParts + newParts)
+ } else {
+ message.content = .contentParts(newParts)
+ }
+ case .tool, .function:
+ if case let .text(existingText) = message.content {
+ message.content = .text(existingText + "\n\n" + content)
+ } else {
+ message.content = .text(content)
+ }
+ }
+ } else {
+ switch message.role {
+ case .system, .developer, .assistant, .user:
+ if case let .text(existingText) = message.content {
+ message.content = .text(existingText + "\n\n" + content)
+ } else {
+ message.content = .text(content)
+ }
+ case .tool, .function:
+ if case let .text(existingText) = message.content {
+ message.content = .text(existingText + "\n\n" + content)
+ } else {
+ message.content = .text(content)
+ }
+ }
+ }
+ }
+
+ init(
+ _ body: ChatCompletionsRequestBody,
+ endpoint: URL,
+ enforceMessageOrder: Bool,
+ supportsMultipartMessageContent: Bool,
+ requiresBeginWithUserMessage: Bool,
+ canUseTool: Bool,
+ supportsImage: Bool,
+ supportsAudio: Bool,
+ supportsTemperature: Bool,
+ supportsSystemPrompt: Bool
+ ) {
+ let supportsMultipartMessageContent = if supportsAudio || supportsImage {
+ true
+ } else {
+ supportsMultipartMessageContent
+ }
+ temperature = body.temperature
+ stream = body.stream
+ stop = body.stop
+ max_completion_tokens = body.maxTokens
+ tool_choice = body.toolChoice
+ tools = body.tools?.map {
+ Tool(
+ type: $0.type,
+ function: $0.function
+ )
+ }
+ stream_options = if body.stream ?? false {
+ StreamOptions()
+ } else {
+ nil
+ }
+
model = body.model
+
+ var body = body
+
+ if !supportsTemperature {
+ temperature = nil
+ }
+ if !supportsSystemPrompt {
+ for (index, message) in body.messages.enumerated() {
+ if message.role == .system {
+ body.messages[index].role = .user
+ }
+ }
+ }
+
+ if requiresBeginWithUserMessage {
+ let firstUserIndex = body.messages.firstIndex(where: { $0.role == .user }) ?? 0
+ let endIndex = firstUserIndex
+ for i in stride(from: endIndex - 1, to: 0, by: -1)
+ where i >= 0 && body.messages.endIndex > i
+ {
+ body.messages.remove(at: i)
+ }
+ }
+
+ // Special case for Claude through OpenRouter
+
+ if endpoint.absoluteString.contains("openrouter.ai"), model.hasPrefix("anthropic/") {
+ body.model = model.replacingOccurrences(of: "anthropic/", with: "")
+ let claudeRequestBody = ClaudeChatCompletionsService.RequestBody(body)
+ messages = claudeRequestBody.system.map {
+ Message(
+ role: .system,
+ content: .contentParts([.text(.init(
+ text: $0.text,
+ cache_control: $0.cache_control
+ ))])
+ )
+ } + claudeRequestBody.messages.map {
+ (message: ClaudeChatCompletionsService.RequestBody.Message) in
+ let role: OpenAIChatCompletionsService.MessageRole = switch message.role {
+ case .user: .user
+ case .assistant: .assistant
+ }
+ return Message(
+ role: role,
+ content: .contentParts(message.content.compactMap(Self.convertContentPart)),
+ name: nil,
+ tool_calls: nil,
+ tool_call_id: nil
+ )
+ }
+ return
+ }
+
+ // Enforce message order
+
if enforceMessageOrder {
- var systemPrompts = [String]()
+ var systemPrompts = [Message.MessageContent.ContentPart]()
var nonSystemMessages = [Message]()
for message in body.messages {
switch (message.role, canUseTool) {
case (.system, _):
- systemPrompts.append(message.content)
+ systemPrompts.append(contentsOf: Self.convertContentPart(
+ content: message.content,
+ images: supportsImage ? message.images : [],
+ audios: supportsAudio ? message.audios : []
+ ))
case (.tool, true):
if let last = nonSystemMessages.last, last.role == .tool {
- nonSystemMessages[nonSystemMessages.endIndex - 1].content
- += "\n\n\(message.content)"
+ Self.joinMessageContent(
+ &nonSystemMessages[nonSystemMessages.endIndex - 1],
+ content: message.content,
+ images: supportsImage ? message.images : [],
+ audios: supportsAudio ? message.audios : [],
+ supportsMultipartMessageContent: supportsMultipartMessageContent
+ )
} else {
nonSystemMessages.append(.init(
role: .tool,
- content: message.content,
+ content: {
+ if supportsMultipartMessageContent {
+ return .contentParts(Self.convertContentPart(
+ content: message.content,
+ images: supportsImage ? message.images : [],
+ audios: supportsAudio ? message.audios : []
+ ))
+ }
+ return .text(message.content)
+ }(),
tool_calls: message.toolCalls?.map { tool in
MessageToolCall(
id: tool.id,
@@ -516,19 +1185,50 @@ extension OpenAIChatCompletionsService.RequestBody {
}
case (.assistant, _), (.tool, false):
if let last = nonSystemMessages.last, last.role == .assistant {
- nonSystemMessages[nonSystemMessages.endIndex - 1].content
- += "\n\n\(message.content)"
+ Self.joinMessageContent(
+ &nonSystemMessages[nonSystemMessages.endIndex - 1],
+ content: message.content,
+ images: supportsImage ? message.images : [],
+ audios: supportsAudio ? message.audios : [],
+ supportsMultipartMessageContent: supportsMultipartMessageContent
+ )
} else {
- nonSystemMessages.append(.init(role: .assistant, content: message.content))
+ nonSystemMessages.append(.init(
+ role: .assistant,
+ content: {
+ if supportsMultipartMessageContent {
+ return .contentParts(Self.convertContentPart(
+ content: message.content,
+ images: supportsImage ? message.images : [],
+ audios: supportsAudio ? message.audios : []
+ ))
+ }
+ return .text(message.content)
+ }()
+ ))
}
case (.user, _):
if let last = nonSystemMessages.last, last.role == .user {
- nonSystemMessages[nonSystemMessages.endIndex - 1].content
- += "\n\n\(message.content)"
+ Self.joinMessageContent(
+ &nonSystemMessages[nonSystemMessages.endIndex - 1],
+ content: message.content,
+ images: supportsImage ? message.images : [],
+ audios: supportsAudio ? message.audios : [],
+ supportsMultipartMessageContent: supportsMultipartMessageContent
+ )
} else {
nonSystemMessages.append(.init(
role: .user,
- content: message.content,
+ content: {
+ if supportsMultipartMessageContent {
+ return .contentParts(Self.convertContentPart(
+ content: message.content,
+ images: supportsImage ? message.images : [],
+ audios: supportsAudio ? message.audios : []
+ ))
+ }
+ return .text(message.content)
+ }(),
name: message.name,
tool_call_id: message.toolCallId
))
@@ -538,50 +1238,66 @@ extension OpenAIChatCompletionsService.RequestBody {
messages = [
.init(
role: .system,
- content: systemPrompts.joined(separator: "\n\n")
- .trimmingCharacters(in: .whitespacesAndNewlines)
+ content: {
+ if supportsMultipartMessageContent {
+ return .contentParts(systemPrompts)
+ }
+ let textParts = systemPrompts.compactMap {
+ if case let .text(text) = $0 { return text.text }
+ return nil
+ }
+
+ return .text(textParts.joined(separator: "\n\n"))
+ }()
),
] + nonSystemMessages
- } else {
- messages = body.messages.map { message in
- .init(
- role: {
- switch message.role {
- case .user:
- return .user
- case .assistant:
- return .assistant
- case .system:
- return .system
- case .tool:
- return .tool
+
+ return
+ }
+
+ // Default
+
+ messages = body.messages.map { message in
+ .init(
+ role: {
+ switch message.role {
+ case .user:
+ return .user
+ case .assistant:
+ return .assistant
+ case .system:
+ return .system
+ case .tool:
+ return .tool
+ }
+ }(),
+ content: {
+ // always prefer text only content if possible.
+ if supportsMultipartMessageContent {
+ 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
+ ))
}
- }(),
- content: message.content,
- name: message.name,
- tool_calls: message.toolCalls?.map { tool in
- MessageToolCall(
- id: tool.id,
- type: tool.type,
- function: MessageFunctionCall(
- name: tool.function.name,
- arguments: tool.function.arguments
- )
+ }
+ return .text(message.content)
+ }(),
+ name: message.name,
+ tool_calls: message.toolCalls?.map { tool in
+ MessageToolCall(
+ id: tool.id,
+ type: tool.type,
+ function: MessageFunctionCall(
+ name: tool.function.name,
+ arguments: tool.function.arguments
)
- },
- tool_call_id: message.toolCallId
- )
- }
- }
- temperature = body.temperature
- stream = body.stream
- stop = body.stop
- max_tokens = body.maxTokens
- tool_choice = body.toolChoice
- tools = body.tools?.map {
- Tool(
- type: $0.type,
- function: $0.function
+ )
+ },
+ tool_call_id: message.toolCallId
)
}
}
diff --git a/Tool/Sources/OpenAIService/APIs/OpenAIEmbeddingService.swift b/Tool/Sources/OpenAIService/APIs/OpenAIEmbeddingService.swift
index acd78b48..f6edf3b7 100644
--- a/Tool/Sources/OpenAIService/APIs/OpenAIEmbeddingService.swift
+++ b/Tool/Sources/OpenAIService/APIs/OpenAIEmbeddingService.swift
@@ -16,6 +16,7 @@ struct OpenAIEmbeddingService: EmbeddingAPI {
let apiKey: String
let model: EmbeddingModel
let endpoint: String
+ var requestModifier: ((inout URLRequest) -> Void)? = nil
public func embed(text: String) async throws -> EmbeddingResponse {
return try await embed(texts: [text])
@@ -23,6 +24,13 @@ struct OpenAIEmbeddingService: EmbeddingAPI {
public func embed(texts text: [String]) async throws -> EmbeddingResponse {
guard let url = URL(string: endpoint) else { throw ChatGPTServiceError.endpointIncorrect }
+ if text.isEmpty {
+ return .init(
+ data: [],
+ model: model.info.modelName,
+ usage: .init(prompt_tokens: 0, total_tokens: 0)
+ )
+ }
var request = URLRequest(url: url)
request.httpMethod = "POST"
let encoder = JSONEncoder()
@@ -34,6 +42,8 @@ struct OpenAIEmbeddingService: EmbeddingAPI {
Self.setupAppInformation(&request)
Self.setupAPIKey(&request, model: model, apiKey: apiKey)
+ await Self.setupExtraHeaderFields(&request, model: model, apiKey: apiKey)
+ requestModifier?(&request)
let (result, response) = try await URLSession.shared.data(for: request)
guard let response = response as? HTTPURLResponse else {
@@ -50,20 +60,18 @@ struct OpenAIEmbeddingService: EmbeddingAPI {
}
let embeddingResponse = try JSONDecoder().decode(EmbeddingResponse.self, from: result)
- #if DEBUG
- Logger.service.info("""
- Embedding usage
- - number of strings: \(text.count)
- - prompt tokens: \(embeddingResponse.usage.prompt_tokens)
- - total tokens: \(embeddingResponse.usage.total_tokens)
-
- """)
- #endif
return embeddingResponse
}
public func embed(tokens: [[Int]]) async throws -> EmbeddingResponse {
guard let url = URL(string: endpoint) else { throw ChatGPTServiceError.endpointIncorrect }
+ if tokens.isEmpty {
+ return .init(
+ data: [],
+ model: model.info.modelName,
+ usage: .init(prompt_tokens: 0, total_tokens: 0)
+ )
+ }
var request = URLRequest(url: url)
request.httpMethod = "POST"
let encoder = JSONEncoder()
@@ -75,6 +83,8 @@ struct OpenAIEmbeddingService: EmbeddingAPI {
Self.setupAppInformation(&request)
Self.setupAPIKey(&request, model: model, apiKey: apiKey)
+ await Self.setupExtraHeaderFields(&request, model: model, apiKey: apiKey)
+ requestModifier?(&request)
let (result, response) = try await URLSession.shared.data(for: request)
guard let response = response as? HTTPURLResponse else {
@@ -91,15 +101,6 @@ struct OpenAIEmbeddingService: EmbeddingAPI {
}
let embeddingResponse = try JSONDecoder().decode(EmbeddingResponse.self, from: result)
- #if DEBUG
- Logger.service.info("""
- Embedding usage
- - number of strings: \(tokens.count)
- - prompt tokens: \(embeddingResponse.usage.prompt_tokens)
- - total tokens: \(embeddingResponse.usage.total_tokens)
-
- """)
- #endif
return embeddingResponse
}
@@ -138,6 +139,8 @@ struct OpenAIEmbeddingService: EmbeddingAPI {
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
case .azureOpenAI:
request.setValue(apiKey, forHTTPHeaderField: "api-key")
+ case .gitHubCopilot:
+ break
case .ollama:
assertionFailure("Unsupported")
}
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 a35d8874..b02f4f2b 100644
--- a/Tool/Sources/OpenAIService/ChatGPTService.swift
+++ b/Tool/Sources/OpenAIService/ChatGPTService.swift
@@ -4,6 +4,7 @@ import ChatBasic
import Dependencies
import Foundation
import IdentifiedCollections
+import Logger
import Preferences
public enum ChatGPTServiceError: Error, LocalizedError {
@@ -63,9 +64,16 @@ public struct ChatGPTError: Error, Codable, LocalizedError {
}
public enum ChatGPTResponse: Equatable {
- case status(String)
+ case status([String])
case partialText(String)
+ case partialReasoning(String)
case toolCalls([ChatMessage.ToolCall])
+ case usage(
+ promptTokens: Int,
+ completionTokens: Int,
+ cachedTokens: Int,
+ otherUsage: [String: Int]
+ )
}
public typealias ChatGPTResponseStream = AsyncThrowingStream
@@ -104,13 +112,17 @@ public protocol ChatGPTServiceType {
public class ChatGPTService: ChatGPTServiceType {
public var configuration: ChatGPTConfiguration
+ public var utilityConfiguration: ChatGPTConfiguration
public var functionProvider: ChatGPTFunctionProvider
public init(
configuration: ChatGPTConfiguration = UserPreferenceChatGPTConfiguration(),
+ utilityConfiguration: ChatGPTConfiguration =
+ UserPreferenceChatGPTConfiguration(chatModelKey: \.preferredChatModelIdForUtilities),
functionProvider: ChatGPTFunctionProvider = NoChatGPTFunctionProvider()
) {
self.configuration = configuration
+ self.utilityConfiguration = utilityConfiguration
self.functionProvider = functionProvider
}
@@ -140,7 +152,17 @@ public class ChatGPTService: ChatGPTServiceType {
if !pendingToolCalls.isEmpty {
if configuration.runFunctionsAutomatically {
+ var toolCallStatuses = [String: String]() {
+ didSet {
+ if toolCallStatuses != oldValue {
+ continuation.yield(.status(
+ Array(toolCallStatuses.values).sorted()
+ ))
+ }
+ }
+ }
for toolCall in pendingToolCalls {
+ let id = toolCall.id
for await response in await runFunctionCall(
toolCall,
memory: memory,
@@ -151,13 +173,15 @@ public class ChatGPTService: ChatGPTServiceType {
functionCallResponses.append(.init(
role: .tool,
content: output,
- toolCallId: toolCall.id
+ toolCallId: id
))
case let .status(status):
- continuation.yield(.status(status))
+ toolCallStatuses[id] = status
}
}
+ toolCallStatuses[id] = nil
}
+ toolCallStatuses = [:]
} else {
if !configuration.runFunctionsAutomatically {
continuation.yield(.toolCalls(pendingToolCalls))
@@ -177,10 +201,22 @@ public class ChatGPTService: ChatGPTServiceType {
try Task.checkCancellation()
switch content {
case let .partialText(text):
- continuation.yield(.partialText(text))
+ continuation.yield(ChatGPTResponse.partialText(text))
+
+ case let .partialReasoning(text):
+ continuation.yield(ChatGPTResponse.partialReasoning(text))
case let .partialToolCalls(toolCalls):
guard configuration.runFunctionsAutomatically else { break }
+ var toolCallStatuses = [String: String]() {
+ didSet {
+ if toolCallStatuses != oldValue {
+ continuation.yield(.status(
+ Array(toolCallStatuses.values).sorted()
+ ))
+ }
+ }
+ }
for toolCall in toolCalls.keys.sorted() {
if let toolCallValue = toolCalls[toolCall] {
for await status in await prepareFunctionCall(
@@ -188,10 +224,24 @@ public class ChatGPTService: ChatGPTServiceType {
memory: memory,
sourceMessageId: sourceMessageId
) {
- continuation.yield(.status(status))
+ toolCallStatuses[toolCallValue.id] = status
}
}
}
+ case let .usage(
+ promptTokens,
+ completionTokens,
+ cachedTokens,
+ otherUsage
+ ):
+ continuation.yield(
+ .usage(
+ promptTokens: promptTokens,
+ completionTokens: completionTokens,
+ cachedTokens: cachedTokens,
+ otherUsage: otherUsage
+ )
+ )
}
}
@@ -224,8 +274,15 @@ public class ChatGPTService: ChatGPTServiceType {
extension ChatGPTService {
enum StreamContent {
+ case partialReasoning(String)
case partialText(String)
case partialToolCalls([Int: ChatMessage.ToolCall])
+ case usage(
+ promptTokens: Int,
+ completionTokens: Int,
+ cachedTokens: Int,
+ otherUsage: [String: Int]
+ )
}
enum FunctionCallResult {
@@ -269,8 +326,19 @@ extension ChatGPTService {
references: prompt.references
)
let chunks = try await api()
+ var usage: ChatCompletionResponseBody.Usage = .init(
+ promptTokens: 0,
+ completionTokens: 0,
+ cachedTokens: 0,
+ otherUsage: [:]
+ )
for try await chunk in chunks {
try Task.checkCancellation()
+
+ if let newUsage = chunk.usage {
+ usage.merge(with: newUsage)
+ }
+
guard let delta = chunk.message else { continue }
// The api will always return a function call with JSON object.
@@ -303,8 +371,19 @@ extension ChatGPTService {
if let content = delta.content {
continuation.yield(.partialText(content))
}
+
+ if let reasoning = delta.reasoningContent {
+ continuation.yield(.partialReasoning(reasoning))
+ }
}
+ Logger.service.info("ChatGPT usage: \(usage)")
+ continuation.yield(.usage(
+ promptTokens: usage.promptTokens,
+ completionTokens: usage.completionTokens,
+ cachedTokens: usage.cachedTokens,
+ otherUsage: usage.otherUsage
+ ))
continuation.finish()
} catch let error as CancellationError {
continuation.finish(throwing: error)
@@ -464,9 +543,7 @@ extension ChatGPTService {
let service = ChatGPTService(
configuration: OverridingChatGPTConfiguration(
- overriding: UserPreferenceChatGPTConfiguration(
- chatModelKey: \.preferredChatModelIdForUtilities
- ),
+ overriding: utilityConfiguration,
with: .init(temperature: 0)
),
functionProvider: NoChatGPTFunctionProvider()
@@ -494,13 +571,25 @@ extension ChatGPTService {
stream: Bool
) -> ChatCompletionsRequestBody {
let serviceSupportsFunctionCalling = switch model.format {
- case .openAI, .openAICompatible, .azureOpenAI:
+ case .openAI, .openAICompatible, .azureOpenAI, .gitHubCopilot:
model.info.supportsFunctionCalling
case .ollama, .googleAI, .claude:
false
}
let messages = prompt.history.flatMap { chatMessage in
+ let images = chatMessage.images.map { image in
+ ChatCompletionsRequestBody.Message.Image(
+ base64EncodeData: image.base64EncodedData,
+ format: {
+ switch image.format {
+ case .png: .png
+ case .jpeg: .jpeg
+ case .gif: .gif
+ }
+ }()
+ )
+ }
var all = [ChatCompletionsRequestBody.Message]()
all.append(ChatCompletionsRequestBody.Message(
role: {
@@ -527,7 +616,10 @@ extension ChatGPTService {
} else {
nil
}
- }()
+ }(),
+ images: images,
+ audios: [],
+ cacheIfPossible: chatMessage.cacheIfPossible
))
for call in chatMessage.toolCalls ?? [] {
@@ -576,8 +668,7 @@ extension ChatGPTService {
return requestBody
}
-
-
+
func maxTokenForReply(maxToken: Int, remainingTokens: Int?) -> Int? {
guard let remainingTokens else { return nil }
return min(maxToken / 2, remainingTokens)
diff --git a/Tool/Sources/OpenAIService/Configuration/ChatGPTConfiguration.swift b/Tool/Sources/OpenAIService/Configuration/ChatGPTConfiguration.swift
index 3b0bd896..710a2ff0 100644
--- a/Tool/Sources/OpenAIService/Configuration/ChatGPTConfiguration.swift
+++ b/Tool/Sources/OpenAIService/Configuration/ChatGPTConfiguration.swift
@@ -39,7 +39,7 @@ public extension ChatGPTConfiguration {
}
}
-public class OverridingChatGPTConfiguration: ChatGPTConfiguration {
+public final class OverridingChatGPTConfiguration: ChatGPTConfiguration {
public struct Overriding: Codable {
public var temperature: Double?
public var modelId: String?
@@ -120,7 +120,7 @@ public class OverridingChatGPTConfiguration: ChatGPTConfiguration {
public var apiKey: String {
if let apiKey = overriding.apiKey { return apiKey }
guard let name = model?.info.apiKeyName else { return configuration.apiKey }
- return (try? Keychain.apiKey.get(name)) ?? configuration.apiKey
+ return (try? Keychain.apiKey.get(name)) ?? ""
}
public var shouldEndTextWindow: (String) -> Bool {
diff --git a/Tool/Sources/OpenAIService/Debug/Debug.swift b/Tool/Sources/OpenAIService/Debug/Debug.swift
index 31864964..37db7031 100644
--- a/Tool/Sources/OpenAIService/Debug/Debug.swift
+++ b/Tool/Sources/OpenAIService/Debug/Debug.swift
@@ -7,20 +7,15 @@ enum Debugger {
#if DEBUG
static func didSendRequestBody(body: ChatCompletionsRequestBody) {
- do {
- let json = try JSONEncoder().encode(body)
- let center = NotificationCenter.default
- center.post(
- name: .init("ServiceDebugger.ChatRequestDebug.requestSent"),
- object: nil,
- userInfo: [
- "id": id ?? UUID(),
- "data": json,
- ]
- )
- } catch {
- print("Failed to encode request body: \(error)")
- }
+ let center = NotificationCenter.default
+ center.post(
+ name: .init("ServiceDebugger.ChatRequestDebug.requestSent"),
+ object: nil,
+ userInfo: [
+ "id": id ?? UUID(),
+ "data": body,
+ ]
+ )
}
static func didReceiveFunction(name: String, arguments: String) {
diff --git a/Tool/Sources/OpenAIService/EmbeddingService.swift b/Tool/Sources/OpenAIService/EmbeddingService.swift
index 0e54d3ac..d0bf1116 100644
--- a/Tool/Sources/OpenAIService/EmbeddingService.swift
+++ b/Tool/Sources/OpenAIService/EmbeddingService.swift
@@ -23,9 +23,14 @@ public struct EmbeddingService {
).embed(text: text)
case .ollama:
embeddingResponse = try await OllamaEmbeddingService(
+ apiKey: configuration.apiKey,
model: model,
endpoint: configuration.endpoint
).embed(text: text)
+ case .gitHubCopilot:
+ embeddingResponse = try await GitHubCopilotEmbeddingService(
+ model: model
+ ).embed(text: text)
}
#if DEBUG
@@ -54,9 +59,14 @@ public struct EmbeddingService {
).embed(texts: text)
case .ollama:
embeddingResponse = try await OllamaEmbeddingService(
+ apiKey: configuration.apiKey,
model: model,
endpoint: configuration.endpoint
).embed(texts: text)
+ case .gitHubCopilot:
+ embeddingResponse = try await GitHubCopilotEmbeddingService(
+ model: model
+ ).embed(texts: text)
}
#if DEBUG
@@ -85,9 +95,14 @@ public struct EmbeddingService {
).embed(tokens: tokens)
case .ollama:
embeddingResponse = try await OllamaEmbeddingService(
+ apiKey: configuration.apiKey,
model: model,
endpoint: configuration.endpoint
).embed(tokens: tokens)
+ case .gitHubCopilot:
+ embeddingResponse = try await GitHubCopilotEmbeddingService(
+ model: model
+ ).embed(tokens: tokens)
}
#if DEBUG
diff --git a/Tool/Sources/OpenAIService/FucntionCall/ChatGPTFuntionProvider.swift b/Tool/Sources/OpenAIService/FucntionCall/ChatGPTFuntionProvider.swift
index fd0bd460..23a9b729 100644
--- a/Tool/Sources/OpenAIService/FucntionCall/ChatGPTFuntionProvider.swift
+++ b/Tool/Sources/OpenAIService/FucntionCall/ChatGPTFuntionProvider.swift
@@ -6,7 +6,7 @@ public protocol ChatGPTFunctionProvider {
var functionCallStrategy: FunctionCallStrategy? { get }
}
-extension ChatGPTFunctionProvider {
+public extension ChatGPTFunctionProvider {
func function(named: String) -> (any ChatGPTFunction)? {
functions.first(where: { $0.name == named })
}
diff --git a/Tool/Sources/OpenAIService/HeaderValueParser.swift b/Tool/Sources/OpenAIService/HeaderValueParser.swift
new file mode 100644
index 00000000..0042ea75
--- /dev/null
+++ b/Tool/Sources/OpenAIService/HeaderValueParser.swift
@@ -0,0 +1,104 @@
+import Foundation
+import GitHubCopilotService
+import Logger
+import Terminal
+
+public struct HeaderValueParser {
+ public enum Placeholder: String {
+ case gitHubCopilotOBearerToken = "github_copilot_bearer_token"
+ case apiKey = "api_key"
+ case modelName = "model_name"
+ }
+
+ public struct Context {
+ public var modelName: String
+ public var apiKey: String
+ public var gitHubCopilotToken: () async -> GitHubCopilotExtension.Token?
+ public var shellEnvironmentVariable: (_ key: String) async -> String?
+
+ public init(
+ modelName: String,
+ apiKey: String,
+ gitHubCopilotToken: (() async -> GitHubCopilotExtension.Token?)? = nil,
+ shellEnvironmentVariable: ((_: String) async -> String?)? = nil
+ ) {
+ self.modelName = modelName
+ self.apiKey = apiKey
+ self.gitHubCopilotToken = gitHubCopilotToken ?? {
+ try? await GitHubCopilotExtension.fetchToken()
+ }
+ self.shellEnvironmentVariable = shellEnvironmentVariable ?? { p in
+ let shell = ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/bash"
+ let terminal = Terminal()
+ return try? await terminal.runCommand(
+ shell,
+ arguments: ["-i", "-l", "-c", "echo $\(p)"],
+ environment: [:]
+ )
+ }
+ }
+ }
+
+ public init() {}
+
+ /// Replace `{{PlaceHolder}}` with exact values.
+ public func parse(_ value: String, context: Context) async -> String {
+ var parsedValue = value
+ let placeholderRanges = findPlaceholderRanges(in: parsedValue)
+
+ for (range, placeholderText) in placeholderRanges.reversed() {
+ let cleanPlaceholder = placeholderText
+ .trimmingCharacters(in: CharacterSet(charactersIn: "{}"))
+
+ var replacement: String?
+ if let knownPlaceholder = Placeholder(rawValue: cleanPlaceholder) {
+ async let token = context.gitHubCopilotToken()
+ switch knownPlaceholder {
+ case .gitHubCopilotOBearerToken:
+ replacement = await token?.token
+ case .apiKey:
+ replacement = context.apiKey
+ case .modelName:
+ replacement = context.modelName
+ }
+ } else {
+ replacement = await context.shellEnvironmentVariable(cleanPlaceholder)
+ }
+
+ if let replacement {
+ parsedValue.replaceSubrange(
+ range,
+ with: replacement.trimmingCharacters(in: .whitespacesAndNewlines)
+ )
+ } else {
+ parsedValue.replaceSubrange(range, with: "none")
+ }
+ }
+
+ return parsedValue
+ }
+
+ private func findPlaceholderRanges(in string: String) -> [(Range, String)] {
+ var ranges: [(Range, String)] = []
+ let pattern = #"\{\{[^}]+\}\}"#
+
+ do {
+ let regex = try NSRegularExpression(pattern: pattern)
+ let matches = regex.matches(
+ in: string,
+ range: NSRange(string.startIndex..., in: string)
+ )
+
+ for match in matches {
+ if let range = Range(match.range, in: string) {
+ ranges.append((range, String(string[range])))
+ }
+ }
+ } catch {
+ Logger.service.error("Failed to find placeholders in string: \(string)")
+ }
+
+ return ranges
+ }
+}
+
diff --git a/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemoryStrategy/AutoManagedChatGPTMemoryOpenAIStrategy.swift b/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemoryStrategy/AutoManagedChatGPTMemoryOpenAIStrategy.swift
index 07a6bda5..a1deaed5 100644
--- a/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemoryStrategy/AutoManagedChatGPTMemoryOpenAIStrategy.swift
+++ b/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemoryStrategy/AutoManagedChatGPTMemoryOpenAIStrategy.swift
@@ -4,6 +4,7 @@ import Logger
import TokenEncoder
extension AutoManagedChatGPTMemory {
+ #warning("TODO: Need to fix the tokenizer or supports model specified tokenizers.")
struct OpenAIStrategy: AutoManagedChatGPTMemoryStrategy {
static let encoder: TokenEncoder = TiktokenCl100kBaseTokenEncoder()
@@ -57,6 +58,10 @@ extension TokenEncoder {
}
return await group.reduce(0, +)
})
+ for image in message.images {
+ encodingContent.append(image.urlString)
+ total += Int(Double(image.urlString.count) * 1.1)
+ }
return total
}
diff --git a/Tool/Sources/OpenAIService/Memory/TemplateChatGPTMemory.swift b/Tool/Sources/OpenAIService/Memory/TemplateChatGPTMemory.swift
index 129474aa..2325e0c4 100644
--- a/Tool/Sources/OpenAIService/Memory/TemplateChatGPTMemory.swift
+++ b/Tool/Sources/OpenAIService/Memory/TemplateChatGPTMemory.swift
@@ -33,7 +33,7 @@ public actor TemplateChatGPTMemory: ChatGPTMemory {
var memoryTemplate = self.memoryTemplate
func checkTokenCount() async -> Bool {
- let history = self.history
+ let history = memoryTemplate.resolved()
var tokenCount = 0
for message in history {
tokenCount += await strategy.countToken(message)
@@ -44,9 +44,15 @@ public actor TemplateChatGPTMemory: ChatGPTMemory {
return tokenCount <= configuration.maxTokens - configuration.minimumReplyTokens
}
+ var truncationTimes = 500
while !(await checkTokenCount()) {
do {
- try memoryTemplate.truncate()
+ truncationTimes -= 1
+ if truncationTimes <= 0 {
+ throw CancellationError()
+ }
+ try Task.checkCancellation()
+ try await memoryTemplate.truncate()
} catch {
Logger.service.error("Failed to truncate prompt template: \(error)")
break
@@ -63,6 +69,10 @@ public struct MemoryTemplate {
public enum Content: ExpressibleByStringLiteral {
case text(String)
case list([String], formatter: ([String]) -> String)
+ case priorityList(
+ [(content: String, priority: Int)],
+ formatter: ([String]) -> String
+ )
public init(stringLiteral value: String) {
self = .text(value)
@@ -70,29 +80,32 @@ public struct MemoryTemplate {
}
public var content: Content
- public var truncatePriority: Int = 0
+ public var priority: Int
public var isEmpty: Bool {
switch content {
case let .text(text):
return text.isEmpty
case let .list(list, _):
return list.isEmpty
+ case let .priorityList(list, _):
+ return list.isEmpty
}
}
public init(stringLiteral value: String) {
content = .text(value)
+ priority = .max
}
- public init(content: Content, truncatePriority: Int = 0) {
+ public init(_ content: Content, priority: Int = .max) {
self.content = content
- self.truncatePriority = truncatePriority
+ self.priority = priority
}
}
public var chatMessage: ChatMessage
public var dynamicContent: [DynamicContent] = []
- public var truncatePriority: Int = 0
+ public var priority: Int
public func resolved() -> ChatMessage? {
var baseMessage = chatMessage
@@ -108,11 +121,15 @@ public struct MemoryTemplate {
return text
case let .list(list, formatter):
return formatter(list)
+ case let .priorityList(list, formatter):
+ return formatter(list.map { $0.0 })
}
}
- baseMessage.content = contents.joined(separator: "\n\n")
+ let composedContent = contents.joined(separator: "\n\n")
+ if composedContent.isEmpty { return nil }
+ baseMessage.content = composedContent
return baseMessage
}
@@ -130,127 +147,153 @@ public struct MemoryTemplate {
public init(
chatMessage: ChatMessage,
dynamicContent: [DynamicContent] = [],
- truncatePriority: Int = 0
+ priority: Int = .max
) {
self.chatMessage = chatMessage
self.dynamicContent = dynamicContent
- self.truncatePriority = truncatePriority
+ self.priority = priority
}
}
public var messages: [Message]
public var followUpMessages: [ChatMessage]
- let truncateRule: ((
+ public typealias TruncateRule = (
_ messages: inout [Message],
_ followUpMessages: inout [ChatMessage]
- ) throws -> Void)?
+ ) async throws -> Void
+
+ let truncateRule: TruncateRule?
+
+ public init(
+ messages: [Message],
+ followUpMessages: [ChatMessage] = [],
+ truncateRule: TruncateRule? = nil
+ ) {
+ self.messages = messages
+ self.truncateRule = truncateRule
+ self.followUpMessages = followUpMessages
+ }
func resolved() -> [ChatMessage] {
messages.compactMap { message in message.resolved() } + followUpMessages
}
- func truncated() throws -> MemoryTemplate {
+ func truncated() async throws -> MemoryTemplate {
var copy = self
- try copy.truncate()
+ try await copy.truncate()
return copy
}
- mutating func truncate() throws {
+ mutating func truncate() async throws {
+ if Task.isCancelled { return }
+
if let truncateRule = truncateRule {
- try truncateRule(&messages, &followUpMessages)
+ try await truncateRule(&messages, &followUpMessages)
return
}
- try Self.defaultTruncateRule(&messages, &followUpMessages)
+ try await Self.defaultTruncateRule()(&messages, &followUpMessages)
+ }
+
+ public struct DefaultTruncateRuleOptions {
+ public var numberOfContentListItemToKeep: (Int) -> Int = { $0 * 2 / 3 }
}
public static func defaultTruncateRule(
- _ messages: inout [Message],
- _ followUpMessages: inout [ChatMessage]
- ) throws {
- // Remove the oldest followup messages when available.
-
- if followUpMessages.count > 20 {
- followUpMessages.removeFirst(followUpMessages.count / 2)
- return
- }
+ options updateOptions: (inout DefaultTruncateRuleOptions) -> Void = { _ in }
+ ) -> TruncateRule {
+ var options = DefaultTruncateRuleOptions()
+ updateOptions(&options)
+ return { messages, followUpMessages in
+
+ // Remove the oldest followup messages when available.
- if followUpMessages.count > 2 {
- if followUpMessages.count.isMultiple(of: 2) {
- followUpMessages.removeFirst(2)
- } else {
- followUpMessages.removeFirst(1)
+ if followUpMessages.count > 20 {
+ followUpMessages.removeFirst(followUpMessages.count / 2)
+ return
}
- return
- }
- // Remove according to the priority.
-
- var truncatingMessageIndex: Int?
- for (index, message) in messages.enumerated() {
- if message.truncatePriority <= 0 { continue }
- if let previousIndex = truncatingMessageIndex,
- message.truncatePriority > messages[previousIndex].truncatePriority
- {
- truncatingMessageIndex = index
+ if followUpMessages.count > 2 {
+ if followUpMessages.count.isMultiple(of: 2) {
+ followUpMessages.removeFirst(2)
+ } else {
+ followUpMessages.removeFirst(1)
+ }
+ return
}
- }
- guard let truncatingMessageIndex else { throw CancellationError() }
- var truncatingMessage: Message {
- get { messages[truncatingMessageIndex] }
- set { messages[truncatingMessageIndex] = newValue }
- }
+ // Remove according to the priority.
- if truncatingMessage.isEmpty {
- messages.remove(at: truncatingMessageIndex)
- return
- }
+ var truncatingMessageIndex: Int?
+ for (index, message) in messages.enumerated() {
+ if message.priority == .max { continue }
+ if let previousIndex = truncatingMessageIndex,
+ message.priority < messages[previousIndex].priority
+ {
+ truncatingMessageIndex = index
+ }
+ }
- truncatingMessage.dynamicContent.removeAll(where: { $0.isEmpty })
+ guard let truncatingMessageIndex else { throw CancellationError() }
+ var truncatingMessage: Message {
+ get { messages[truncatingMessageIndex] }
+ set { messages[truncatingMessageIndex] = newValue }
+ }
- var truncatingContentIndex: Int?
- for (index, content) in truncatingMessage.dynamicContent.enumerated() {
- if content.isEmpty { continue }
- if let previousIndex = truncatingContentIndex,
- content.truncatePriority > truncatingMessage.dynamicContent[previousIndex]
- .truncatePriority
- {
- truncatingContentIndex = index
+ if truncatingMessage.isEmpty {
+ messages.remove(at: truncatingMessageIndex)
+ return
}
- }
- guard let truncatingContentIndex else { throw CancellationError() }
- var truncatingContent: Message.DynamicContent {
- get { truncatingMessage.dynamicContent[truncatingContentIndex] }
- set { truncatingMessage.dynamicContent[truncatingContentIndex] = newValue }
- }
+ truncatingMessage.dynamicContent.removeAll(where: { $0.isEmpty })
- switch truncatingContent.content {
- case .text:
- truncatingMessage.dynamicContent.remove(at: truncatingContentIndex)
- case let .list(list, formatter: formatter):
- let count = list.count * 2 / 3
- if count > 0 {
- truncatingContent.content = .list(
- Array(list.prefix(count)),
- formatter: formatter
- )
- } else {
+ var truncatingContentIndex: Int?
+ for (index, content) in truncatingMessage.dynamicContent.enumerated() {
+ if content.isEmpty { continue }
+ if let previousIndex = truncatingContentIndex,
+ content.priority < truncatingMessage.dynamicContent[previousIndex].priority
+ {
+ 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)
+ case let .list(list, formatter):
+ let count = options.numberOfContentListItemToKeep(list.count)
+ if count > 0 {
+ truncatingContent.content = .list(
+ Array(list.prefix(count)),
+ formatter: formatter
+ )
+ } else {
+ truncatingMessage.dynamicContent.remove(at: truncatingContentIndex)
+ }
+ case let .priorityList(list, formatter):
+ let count = options.numberOfContentListItemToKeep(list.count)
+ if count > 0 {
+ let orderedList = list.enumerated()
+ let orderedByPriority = orderedList
+ .sorted { $0.element.priority >= $1.element.priority }
+ let kept = orderedByPriority.prefix(count)
+ let reordered = kept.sorted { $0.offset < $1.offset }
+ truncatingContent.content = .priorityList(
+ Array(reordered.map { $0.element }),
+ formatter: formatter
+ )
+ } else {
+ truncatingMessage.dynamicContent.remove(at: truncatingContentIndex)
+ }
}
}
}
-
- public init(
- messages: [Message],
- followUpMessages: [ChatMessage] = [],
- truncateRule: ((inout [Message], inout [ChatMessage]) -> Void)? = nil
- ) {
- self.messages = messages
- self.truncateRule = truncateRule
- self.followUpMessages = followUpMessages
- }
}
diff --git a/Tool/Sources/Preferences/Keys.swift b/Tool/Sources/Preferences/Keys.swift
index 28f91c80..7b955d50 100644
--- a/Tool/Sources/Preferences/Keys.swift
+++ b/Tool/Sources/Preferences/Keys.swift
@@ -100,6 +100,11 @@ public struct UserDefaultPreferenceKeys {
defaultValue: false,
key: "InstallBetaBuilds"
)
+
+ public let debugOverlayPanel = PreferenceKey(
+ defaultValue: false,
+ key: "DebugOverlayPanel"
+ )
}
// MARK: - OpenAI Account Settings
@@ -195,6 +200,14 @@ public extension UserDefaultPreferenceKeys {
var gitHubCopilotPretendIDEToBeVSCode: PreferenceKey {
.init(defaultValue: false, key: "GitHubCopilotPretendIDEToBeVSCode")
}
+
+ var gitHubCopilotModelId: PreferenceKey {
+ .init(defaultValue: "", key: "GitHubCopilotModelId")
+ }
+
+ var gitHubCopilotModelFamily: PreferenceKey {
+ .init(defaultValue: "", key: "GitHubCopilotModelFamily")
+ }
}
// MARK: - Codeium Settings
@@ -233,21 +246,7 @@ public extension UserDefaultPreferenceKeys {
public extension UserDefaultPreferenceKeys {
var chatModels: PreferenceKey<[ChatModel]> {
- .init(defaultValue: [
- .init(
- id: UUID().uuidString,
- name: "OpenAI",
- format: .openAI,
- info: .init(
- apiKeyName: "",
- baseURL: "",
- isFullURL: false,
- maxTokens: ChatGPTModel.gpt35Turbo.maxToken,
- supportsFunctionCalling: true,
- modelName: ChatGPTModel.gpt35Turbo.rawValue
- )
- ),
- ], key: "ChatModels")
+ .init(defaultValue: [], key: "ChatModels")
}
var chatGPTLanguage: PreferenceKey {
@@ -267,20 +266,7 @@ public extension UserDefaultPreferenceKeys {
public extension UserDefaultPreferenceKeys {
var embeddingModels: PreferenceKey<[EmbeddingModel]> {
- .init(defaultValue: [
- .init(
- id: UUID().uuidString,
- name: "OpenAI",
- format: .openAI,
- info: .init(
- apiKeyName: "",
- baseURL: "",
- isFullURL: false,
- maxTokens: OpenAIEmbeddingModel.textEmbeddingAda002.maxToken,
- modelName: OpenAIEmbeddingModel.textEmbeddingAda002.rawValue
- )
- ),
- ], key: "EmbeddingModels")
+ .init(defaultValue: [], key: "EmbeddingModels")
}
}
@@ -320,7 +306,7 @@ public extension UserDefaultPreferenceKeys {
}
var wrapCodeInPromptToCode: PreferenceKey {
- .init(defaultValue: true, key: "WrapCodeInPromptToCode")
+ .init(defaultValue: false, key: "WrapCodeInPromptToCode")
}
}
@@ -395,6 +381,10 @@ public extension UserDefaultPreferenceKeys {
.init(defaultValue: false, key: "SuggestionWithModifierOnlyForSwift")
}
+ var acceptSuggestionLineWithModifierControl: PreferenceKey {
+ .init(defaultValue: true, key: "SuggestionLineWithModifierControl")
+ }
+
var dismissSuggestionWithEsc: PreferenceKey {
.init(defaultValue: true, key: "DismissSuggestionWithEsc")
}
@@ -453,15 +443,7 @@ public extension UserDefaultPreferenceKeys {
var defaultChatSystemPrompt: PreferenceKey {
.init(
- defaultValue: """
- You are a helpful senior programming assistant.
- You should respond in natural language.
- Your response should be correct, concise, clear, informative and logical.
- Use markdown if you need to present code, table, list, etc.
- If you are asked to help perform a task, you MUST think step-by-step, then describe each step concisely.
- If you are asked to explain code, you MUST explain it step-by-step in a ordered list concisely.
- Make your answer short and structured.
- """,
+ defaultValue: "",
key: "DefaultChatSystemPrompt"
)
}
@@ -471,7 +453,7 @@ public extension UserDefaultPreferenceKeys {
}
var wrapCodeInChatCodeBlock: PreferenceKey {
- .init(defaultValue: true, key: "WrapCodeInChatCodeBlock")
+ .init(defaultValue: false, key: "WrapCodeInChatCodeBlock")
}
var enableFileScopeByDefaultInChatContext: PreferenceKey {
@@ -505,20 +487,34 @@ public extension UserDefaultPreferenceKeys {
var preferredChatModelIdForWebScope: PreferenceKey {
.init(defaultValue: "", key: "PreferredChatModelIdForWebScope")
}
-
+
var preferredChatModelIdForUtilities: PreferenceKey {
.init(defaultValue: "", key: "PreferredChatModelIdForUtilities")
}
+ enum ChatPanelFloatOnTopOption: Int, Codable, Equatable {
+ case alwaysOnTop
+ case onTopWhenXcodeIsActive
+ case never
+ }
+
+ var chatPanelFloatOnTopOption: PreferenceKey {
+ .init(defaultValue: .onTopWhenXcodeIsActive, key: "ChatPanelFloatOnTopOption")
+ }
+
var disableFloatOnTopWhenTheChatPanelIsDetached: PreferenceKey {
- .init(defaultValue: true, key: "DisableFloatOnTopWhenTheChatPanelIsDetached")
+ .init(defaultValue: false, key: "DisableFloatOnTopWhenTheChatPanelIsDetached")
}
var keepFloatOnTopIfChatPanelAndXcodeOverlaps: PreferenceKey {
.init(defaultValue: true, key: "KeepFloatOnTopIfChatPanelAndXcodeOverlaps")
}
- var openChatMode: PreferenceKey {
+ var openChatMode: PreferenceKey> {
+ .init(defaultValue: .init(.chatPanel), key: "DefaultOpenChatMode")
+ }
+
+ var legacyOpenChatMode: DeprecatedPreferenceKey {
.init(defaultValue: .chatPanel, key: "OpenChatMode")
}
@@ -591,14 +587,49 @@ public extension UserDefaultPreferenceKeys {
}
}
-// MARK: - Bing Search
+// MARK: - Search
public extension UserDefaultPreferenceKeys {
- var bingSearchSubscriptionKey: PreferenceKey {
+ enum SearchProvider: String, Codable, CaseIterable {
+ case headlessBrowser
+ case serpAPI
+ }
+
+ enum SerpAPIEngine: String, Codable, CaseIterable {
+ case google
+ case baidu
+ case bing
+ case duckDuckGo = "duckduckgo"
+ }
+
+ enum HeadlessBrowserEngine: String, Codable, CaseIterable {
+ case google
+ case baidu
+ case bing
+ case duckDuckGo = "duckduckgo"
+ }
+
+ var searchProvider: PreferenceKey {
+ .init(defaultValue: .headlessBrowser, key: "SearchProvider")
+ }
+
+ var serpAPIEngine: PreferenceKey {
+ .init(defaultValue: .google, key: "SerpAPIEngine")
+ }
+
+ var serpAPIKeyName: PreferenceKey {
+ .init(defaultValue: "", key: "SerpAPIKeyName")
+ }
+
+ var headlessBrowserEngine: PreferenceKey {
+ .init(defaultValue: .google, key: "HeadlessBrowserEngine")
+ }
+
+ var bingSearchSubscriptionKey: DeprecatedPreferenceKey {
.init(defaultValue: "", key: "BingSearchSubscriptionKey")
}
- var bingSearchEndpoint: PreferenceKey {
+ var bingSearchEndpoint: DeprecatedPreferenceKey {
.init(
defaultValue: "https://api.bing.microsoft.com/v7.0/search/",
key: "BingSearchEndpoint"
@@ -618,7 +649,9 @@ public extension UserDefaultPreferenceKeys {
extraSystemPrompt: "",
prompt: "Explain the selected code concisely, step-by-step.",
useExtraSystemPrompt: true
- )
+ ),
+ ignoreExistingAttachments: false,
+ attachments: []
),
.init(
commandId: "BuiltInCustomCommandAddDocumentationToSelection",
@@ -628,7 +661,9 @@ public extension UserDefaultPreferenceKeys {
prompt: "Add documentation on top of the code. Use triple slash if the language supports it.",
continuousMode: false,
generateDescription: true
- )
+ ),
+ ignoreExistingAttachments: false,
+ attachments: []
),
.init(
commandId: "BuiltInCustomCommandSendCodeToChat",
@@ -641,7 +676,9 @@ public extension UserDefaultPreferenceKeys {
```
""",
useExtraSystemPrompt: true
- )
+ ),
+ ignoreExistingAttachments: false,
+ attachments: []
),
], key: "CustomCommands")
}
@@ -748,7 +785,7 @@ public extension UserDefaultPreferenceKeys {
var useCloudflareDomainNameForLicenseCheck: FeatureFlag {
.init(defaultValue: false, key: "FeatureFlag-UseCloudflareDomainNameForLicenseCheck")
}
-
+
var doNotInstallLaunchAgentAutomatically: FeatureFlag {
.init(defaultValue: false, key: "FeatureFlag-DoNotInstallLaunchAgentAutomatically")
}
diff --git a/Tool/Sources/Preferences/Types/ChatGPTModel.swift b/Tool/Sources/Preferences/Types/ChatGPTModel.swift
index 8ac04faf..54893fb6 100644
--- a/Tool/Sources/Preferences/Types/ChatGPTModel.swift
+++ b/Tool/Sources/Preferences/Types/ChatGPTModel.swift
@@ -8,21 +8,19 @@ public enum ChatGPTModel: String, CaseIterable {
case gpt4 = "gpt-4"
case gpt432k = "gpt-4-32k"
case gpt4Turbo = "gpt-4-turbo"
- case gpt40314 = "gpt-4-0314"
- case gpt40613 = "gpt-4-0613"
- case gpt41106Preview = "gpt-4-1106-preview"
case gpt4VisionPreview = "gpt-4-vision-preview"
- case gpt4TurboPreview = "gpt-4-turbo-preview"
- case gpt4Turbo20240409 = "gpt-4-turbo-2024-04-09"
- case gpt35Turbo1106 = "gpt-3.5-turbo-1106"
- case gpt35Turbo0125 = "gpt-3.5-turbo-0125"
case gpt432k0314 = "gpt-4-32k-0314"
case gpt432k0613 = "gpt-4-32k-0613"
case gpt40125 = "gpt-4-0125-preview"
+ case gpt4_1 = "gpt-4.1"
+ case gpt4_1Mini = "gpt-4.1-mini"
+ case gpt4_1Nano = "gpt-4.1-nano"
+ case o1 = "o1"
case o1Preview = "o1-preview"
- case o1Preview20240912 = "o1-preview-2024-09-12"
- case o1Mini = "o1-mini"
- case o1Mini20240912 = "o1-mini-2024-09-12"
+ case o1Pro = "o1-pro"
+ case o3Mini = "o3-mini"
+ case o3 = "o3"
+ case o4Mini = "o4-mini"
}
public extension ChatGPTModel {
@@ -30,55 +28,72 @@ public extension ChatGPTModel {
switch self {
case .gpt4:
return 8192
- case .gpt40314:
- return 8192
case .gpt432k:
return 32768
case .gpt432k0314:
return 32768
case .gpt35Turbo:
return 16385
- case .gpt35Turbo1106:
- return 16385
- case .gpt35Turbo0125:
- return 16385
case .gpt35Turbo16k:
return 16385
- case .gpt40613:
- return 8192
case .gpt432k0613:
return 32768
- case .gpt41106Preview:
- return 128_000
case .gpt4VisionPreview:
return 128_000
- case .gpt4TurboPreview:
- return 128_000
case .gpt40125:
return 128_000
case .gpt4Turbo:
return 128_000
- case .gpt4Turbo20240409:
- return 128_000
case .gpt4o:
return 128_000
case .gpt4oMini:
return 128_000
- case .o1Preview, .o1Preview20240912:
- return 128_000
- case .o1Mini, .o1Mini20240912:
+ case .o1Preview:
return 128_000
+ case .o1:
+ return 200_000
+ case .o3Mini:
+ return 200_000
+ case .gpt4_1:
+ return 1_047_576
+ case .gpt4_1Mini:
+ return 1_047_576
+ case .gpt4_1Nano:
+ return 1_047_576
+ case .o1Pro:
+ return 200_000
+ case .o3:
+ return 200_000
+ case .o4Mini:
+ return 200_000
}
}
var supportsImages: Bool {
switch self {
- case .gpt4VisionPreview, .gpt4Turbo, .gpt4Turbo20240409, .gpt4o, .gpt4oMini, .o1Preview,
- .o1Preview20240912, .o1Mini, .o1Mini20240912:
+ case .gpt4VisionPreview, .gpt4Turbo, .gpt4o, .gpt4oMini, .o1Preview, .o1, .o3Mini:
return true
default:
return false
}
}
+
+ var supportsTemperature: Bool {
+ switch self {
+ case .o1Preview, .o1, .o3Mini:
+ return false
+ default:
+ return true
+ }
+ }
+
+ var supportsSystemPrompt: Bool {
+ switch self {
+ case .o1Preview, .o1, .o3Mini:
+ return false
+ default:
+ return true
+ }
+ }
}
diff --git a/Tool/Sources/Preferences/Types/CustomCommand.swift b/Tool/Sources/Preferences/Types/CustomCommand.swift
index b462e8a3..38c837e0 100644
--- a/Tool/Sources/Preferences/Types/CustomCommand.swift
+++ b/Tool/Sources/Preferences/Types/CustomCommand.swift
@@ -30,27 +30,63 @@ public struct CustomCommand: Codable, Equatable {
)
}
+ public struct Attachment: Codable, Equatable {
+ public enum Kind: Codable, Equatable, Hashable {
+ case activeDocument
+ case debugArea
+ case clipboard
+ case senseScope
+ case projectScope
+ case webScope
+ case gitStatus
+ case gitLog
+ case file(path: String)
+ }
+ public var kind: Kind
+ public init(kind: Kind) {
+ self.kind = kind
+ }
+ }
+
public var id: String { commandId ?? legacyId }
public var commandId: String?
public var name: String
public var feature: Feature
- public init(commandId: String, name: String, feature: Feature) {
+ public var ignoreExistingAttachments: Bool
+ public var attachments: [Attachment]
+
+ public init(
+ commandId: String,
+ name: String,
+ feature: Feature,
+ ignoreExistingAttachments: Bool,
+ attachments: [Attachment]
+ ) {
self.commandId = commandId
self.name = name
self.feature = feature
+ self.ignoreExistingAttachments = ignoreExistingAttachments
+ self.attachments = attachments
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
commandId = try container.decodeIfPresent(String.self, forKey: .commandId)
name = try container.decode(String.self, forKey: .name)
- feature = (try? container
- .decode(CustomCommand.Feature.self, forKey: .feature)) ?? .chatWithSelection(
- extraSystemPrompt: "",
- prompt: "",
- useExtraSystemPrompt: false
- )
+ feature = (
+ try? container
+ .decode(CustomCommand.Feature.self, forKey: .feature)
+ ) ?? .chatWithSelection(
+ extraSystemPrompt: "",
+ prompt: "",
+ useExtraSystemPrompt: false
+ )
+ ignoreExistingAttachments = try container.decodeIfPresent(
+ Bool.self,
+ forKey: .ignoreExistingAttachments
+ ) ?? false
+ attachments = try container.decodeIfPresent([Attachment].self, forKey: .attachments) ?? []
}
var legacyId: String {
diff --git a/Tool/Sources/Preferences/Types/GoogleGenerativeChatModel.swift b/Tool/Sources/Preferences/Types/GoogleGenerativeChatModel.swift
index 43e4af28..23de7f5e 100644
--- a/Tool/Sources/Preferences/Types/GoogleGenerativeChatModel.swift
+++ b/Tool/Sources/Preferences/Types/GoogleGenerativeChatModel.swift
@@ -1,6 +1,10 @@
import Foundation
public enum GoogleGenerativeAIModel: String {
+ case gemini25FlashPreview = "gemini-2.5-flash-preview-04-17"
+ case gemini25ProPreview = "gemini-2.5-pro-preview-05-06"
+ case gemini20Flash = "gemini-2.0-flash"
+ case gemini20FlashLite = "gemini-2.0-flash-lite"
case gemini15Pro = "gemini-1.5-pro"
case gemini15Flash = "gemini-1.5-flash"
case geminiPro = "gemini-pro"
@@ -15,6 +19,14 @@ public extension GoogleGenerativeAIModel {
return 1_048_576
case .gemini15Pro:
return 2_097_152
+ case .gemini25FlashPreview:
+ return 1_048_576
+ case .gemini25ProPreview:
+ return 1_048_576
+ case .gemini20Flash:
+ return 1_048_576
+ case .gemini20FlashLite:
+ return 1_048_576
}
}
}
diff --git a/Tool/Sources/Preferences/Types/OpenAIEmbeddingModel.swift b/Tool/Sources/Preferences/Types/OpenAIEmbeddingModel.swift
index 76cc5498..64f88fbc 100644
--- a/Tool/Sources/Preferences/Types/OpenAIEmbeddingModel.swift
+++ b/Tool/Sources/Preferences/Types/OpenAIEmbeddingModel.swift
@@ -1,4 +1,6 @@
public enum OpenAIEmbeddingModel: String, CaseIterable {
+ case textEmbedding3Small = "text-embedding-3-small"
+ case textEmbedding3Large = "text-embedding-3-large"
case textEmbeddingAda002 = "text-embedding-ada-002"
}
@@ -7,6 +9,21 @@ public extension OpenAIEmbeddingModel {
switch self {
case .textEmbeddingAda002:
return 8191
+ case .textEmbedding3Small:
+ return 8191
+ case .textEmbedding3Large:
+ return 8191
+ }
+ }
+
+ var dimensions: Int {
+ switch self {
+ case .textEmbeddingAda002:
+ return 1536
+ case .textEmbedding3Small:
+ return 1536
+ case .textEmbedding3Large:
+ return 3072
}
}
}
diff --git a/Tool/Sources/Preferences/Types/OpenChatMode.swift b/Tool/Sources/Preferences/Types/OpenChatMode.swift
index fc9f4f4c..c744a6b5 100644
--- a/Tool/Sources/Preferences/Types/OpenChatMode.swift
+++ b/Tool/Sources/Preferences/Types/OpenChatMode.swift
@@ -1,7 +1,28 @@
import Foundation
-public enum OpenChatMode: String, CaseIterable {
+public enum OpenChatMode: Codable, Equatable, Identifiable, Hashable {
+ public var id: String {
+ switch self {
+ case .chatPanel:
+ return "chatPanel"
+ case .browser:
+ return "browser"
+ case let .builtinExtension(extensionIdentifier, id, _):
+ return "builtinExtension-\(extensionIdentifier)-\(id)"
+ case let .externalExtension(extensionIdentifier, id, _):
+ return "externalExtension-\(extensionIdentifier)-\(id)"
+ }
+ }
+
+ public enum LegacyOpenChatMode: String {
+ case chatPanel
+ case browser
+ case codeiumChat
+ }
+
case chatPanel
case browser
- case codeiumChat
+ case builtinExtension(extensionIdentifier: String, id: String, tabName: String)
+ case externalExtension(extensionIdentifier: String, id: String, tabName: String)
}
+
diff --git a/Tool/Sources/Preferences/Types/StorableColors.swift b/Tool/Sources/Preferences/Types/StorableColors.swift
index 61dcbb51..070092c0 100644
--- a/Tool/Sources/Preferences/Types/StorableColors.swift
+++ b/Tool/Sources/Preferences/Types/StorableColors.swift
@@ -18,7 +18,7 @@ public struct StorableColor: Codable, Equatable {
import SwiftUI
public extension StorableColor {
var swiftUIColor: SwiftUI.Color {
- SwiftUI.Color(.sRGB, red: red, green: green, blue: blue, opacity: alpha)
+ SwiftUI.Color(nsColor: nsColor)
}
}
#endif
@@ -28,7 +28,7 @@ import AppKit
public extension StorableColor {
var nsColor: NSColor {
NSColor(
- srgbRed: CGFloat(red),
+ calibratedRed: CGFloat(red),
green: CGFloat(green),
blue: CGFloat(blue),
alpha: CGFloat(alpha)
diff --git a/Tool/Sources/Preferences/UserDefaults.swift b/Tool/Sources/Preferences/UserDefaults.swift
index 982edf9e..078ca58c 100644
--- a/Tool/Sources/Preferences/UserDefaults.swift
+++ b/Tool/Sources/Preferences/UserDefaults.swift
@@ -51,6 +51,21 @@ public extension UserDefaults {
weight: .regular
)))
)
+ shared.setupDefaultValue(
+ for: \.openChatMode,
+ defaultValue: {
+ switch shared.deprecatedValue(for: \.legacyOpenChatMode) {
+ case .chatPanel: return .init(.chatPanel)
+ case .browser: return .init(.browser)
+ case .codeiumChat:
+ return .init(.builtinExtension(
+ extensionIdentifier: "com.codeium",
+ id: "Codeium Chat",
+ tabName: "Codeium Chat"
+ ))
+ }
+ }()
+ )
}
}
@@ -65,7 +80,7 @@ extension String: UserDefaultsStorable {}
extension Data: UserDefaultsStorable {}
extension URL: UserDefaultsStorable {}
-extension Array: RawRepresentable where Element: Codable {
+extension Array: @retroactive RawRepresentable where Element: Codable {
public init?(rawValue: String) {
guard let data = rawValue.data(using: .utf8),
let result = try? JSONDecoder().decode([Element].self, from: data)
diff --git a/Tool/Sources/PromptToCodeBasic/PromptToCodeAgent.swift b/Tool/Sources/PromptToCodeBasic/PromptToCodeAgent.swift
deleted file mode 100644
index 321b152b..00000000
--- a/Tool/Sources/PromptToCodeBasic/PromptToCodeAgent.swift
+++ /dev/null
@@ -1,91 +0,0 @@
-import ComposableArchitecture
-import Foundation
-import SuggestionBasic
-
-public enum PromptToCodeAgentResponse {
- case code(String)
- case description(String)
-}
-
-public struct PromptToCodeAgentRequest {
- var code: String
- var requirement: String
- var source: PromptToCodeSource
- var isDetached: Bool
- var extraSystemPrompt: String?
- var generateDescriptionRequirement: Bool?
-
- public struct PromptToCodeSource {
- public var language: CodeLanguage
- public var documentURL: URL
- public var projectRootURL: URL
- public var content: String
- public var lines: [String]
- public var range: CursorRange
-
- public init(
- language: CodeLanguage,
- documentURL: URL,
- projectRootURL: URL,
- content: String,
- lines: [String],
- range: CursorRange
- ) {
- self.language = language
- self.documentURL = documentURL
- self.projectRootURL = projectRootURL
- self.content = content
- self.lines = lines
- self.range = range
- }
- }
-}
-
-public protocol PromptToCodeAgent {
- typealias Request = PromptToCodeAgentRequest
- typealias Response = PromptToCodeAgentResponse
-
- func send(_ request: Request) -> AsyncThrowingStream
-}
-
-public struct PromptToCodeSnippet: Equatable, Identifiable {
- public let id = UUID()
- public var startLineIndex: Int
- public var originalCode: String
- public var modifiedCode: String
- public var description: String
- public var error: String?
- public var attachedRange: CursorRange
-
- public init(
- startLineIndex: Int,
- originalCode: String,
- modifiedCode: String,
- description: String,
- error: String?,
- attachedRange: CursorRange
- ) {
- self.startLineIndex = startLineIndex
- self.originalCode = originalCode
- self.modifiedCode = modifiedCode
- self.description = description
- self.error = error
- self.attachedRange = attachedRange
- }
-}
-
-public enum PromptToCodeAttachedTarget: Equatable {
- case file(URL, projectURL: URL, code: String, lines: [String])
- case dynamic
-}
-
-public struct PromptToCodeHistoryNode: Equatable {
- public var snippets: IdentifiedArrayOf
- public var instruction: String
-
- public init(snippets: IdentifiedArrayOf, instruction: String) {
- self.snippets = snippets
- self.instruction = instruction
- }
-}
-
diff --git a/Tool/Sources/PromptToCodeCustomization/PromptToCodeCustomization.swift b/Tool/Sources/PromptToCodeCustomization/PromptToCodeCustomization.swift
index 703398cd..a952311b 100644
--- a/Tool/Sources/PromptToCodeCustomization/PromptToCodeCustomization.swift
+++ b/Tool/Sources/PromptToCodeCustomization/PromptToCodeCustomization.swift
@@ -1,19 +1,25 @@
+import ChatBasic
import ComposableArchitecture
import Dependencies
import Foundation
-import PromptToCodeBasic
+import ModificationBasic
import SuggestionBasic
import SwiftUI
public enum PromptToCodeCustomization {
public static var CustomizedUI: any PromptToCodeCustomizedUI = NoPromptToCodeCustomizedUI()
+ public static var contextInputControllerFactory: (
+ Shared
+ ) -> PromptToCodeContextInputController = { _ in
+ DefaultPromptToCodeContextInputController()
+ }
}
public struct PromptToCodeCustomizationContextWrapper: View {
@State var context: AnyObject
let content: (AnyObject) -> Content
- init(context: O, @ViewBuilder content: @escaping (O) -> Content) {
+ public init(context: O, @ViewBuilder content: @escaping (O) -> Content) {
self.context = context
self.content = { context in
content(context as! O)
@@ -30,22 +36,44 @@ public protocol PromptToCodeCustomizedUI {
extraMenuItems: AnyView?,
extraButtons: AnyView?,
extraAcceptButtonVariants: AnyView?,
- inputField: AnyView?
+ contextInputField: AnyView?
)
func callAsFunction(
- state: Shared,
- isInputFieldFocused: Binding,
+ state: Shared,
+ delegate: PromptToCodeContextInputControllerDelegate,
+ contextInputController: PromptToCodeContextInputController,
+ isInputFieldFocused: FocusState,
@ViewBuilder view: @escaping (PromptToCodeCustomizedViews) -> V
) -> PromptToCodeCustomizationContextWrapper
}
+public protocol PromptToCodeContextInputControllerDelegate {
+ func modifyCodeButtonClicked()
+}
+
+public protocol PromptToCodeContextInputController: Perception.Perceptible {
+ var instruction: NSAttributedString { get set }
+
+ func resolveContext(
+ forDocumentURL: URL,
+ onStatusChange: @escaping ([String]) async -> Void
+ ) async -> (
+ instruction: String,
+ references: [ChatMessage.Reference],
+ topics: [ChatMessage.Reference],
+ agent: (() -> any ModificationAgent)?
+ )
+}
+
struct NoPromptToCodeCustomizedUI: PromptToCodeCustomizedUI {
private class Context {}
func callAsFunction(
- state: Shared,
- isInputFieldFocused: Binding,
+ state: Shared,
+ delegate: PromptToCodeContextInputControllerDelegate,
+ contextInputController: PromptToCodeContextInputController,
+ isInputFieldFocused: FocusState,
@ViewBuilder view: @escaping (PromptToCodeCustomizedViews) -> V
) -> PromptToCodeCustomizationContextWrapper {
PromptToCodeCustomizationContextWrapper(context: Context()) { _ in
@@ -53,104 +81,43 @@ struct NoPromptToCodeCustomizedUI: PromptToCodeCustomizedUI {
extraMenuItems: nil,
extraButtons: nil,
extraAcceptButtonVariants: nil,
- inputField: nil
+ contextInputField: nil
))
}
}
}
-public struct PromptToCodeState: Equatable {
- public struct Source: Equatable {
- public var language: CodeLanguage
- public var documentURL: URL
- public var projectRootURL: URL
- public var content: String
- public var lines: [String]
-
- public init(
- language: CodeLanguage,
- documentURL: URL,
- projectRootURL: URL,
- content: String,
- lines: [String]
- ) {
- self.language = language
- self.documentURL = documentURL
- self.projectRootURL = projectRootURL
- self.content = content
- self.lines = lines
- }
+@Perceptible
+public final class DefaultPromptToCodeContextInputController: PromptToCodeContextInputController {
+ public var instruction: NSAttributedString = .init()
+ public var instructionString: String {
+ get { instruction.string }
+ set { instruction = .init(string: newValue) }
}
- public var source: Source
- public var history: [PromptToCodeHistoryNode] = []
- public var snippets: IdentifiedArrayOf = []
- public var isGenerating: Bool = false
- public var instruction: String
- public var extraSystemPrompt: String
- public var isAttachedToTarget: Bool = true
-
- public init(
- source: Source,
- history: [PromptToCodeHistoryNode] = [],
- snippets: IdentifiedArrayOf,
- instruction: String,
- extraSystemPrompt: String,
- isAttachedToTarget: Bool
- ) {
- self.history = history
- self.snippets = snippets
- isGenerating = false
- self.instruction = instruction
- self.isAttachedToTarget = isAttachedToTarget
- self.extraSystemPrompt = extraSystemPrompt
- self.source = source
+ public func appendNewLineToPromptButtonTapped() {
+ let mutable = NSMutableAttributedString(
+ attributedString: instruction
+ )
+ mutable.append(NSAttributedString(string: "\n"))
+ instruction = mutable
}
- public init(
- source: Source,
- originalCode: String,
- attachedRange: CursorRange,
+ public func resolveContext(
+ forDocumentURL: URL,
+ onStatusChange: @escaping ([String]) async -> Void
+ ) -> (
instruction: String,
- extraSystemPrompt: String
+ references: [ChatMessage.Reference],
+ topics: [ChatMessage.Reference],
+ agent: (() -> any ModificationAgent)?
) {
- self.init(
- source: source,
- snippets: [
- .init(
- startLineIndex: 0,
- originalCode: originalCode,
- modifiedCode: originalCode,
- description: "",
- error: nil,
- attachedRange: attachedRange
- ),
- ],
- instruction: instruction,
- extraSystemPrompt: extraSystemPrompt,
- isAttachedToTarget: !attachedRange.isEmpty
+ return (
+ instruction: instructionString,
+ references: [],
+ topics: [],
+ agent: nil
)
}
-
- public mutating func popHistory() {
- if !history.isEmpty {
- let last = history.removeLast()
- snippets = last.snippets
- instruction = last.instruction
- }
- }
-
- public mutating func pushHistory() {
- history.append(.init(snippets: snippets, instruction: instruction))
- let oldSnippets = snippets
- snippets = IdentifiedArrayOf()
- for var snippet in oldSnippets {
- snippet.originalCode = snippet.modifiedCode
- snippet.modifiedCode = ""
- snippet.description = ""
- snippet.error = nil
- snippets.append(snippet)
- }
- }
}
diff --git a/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift b/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift
index 8969ef6c..22c616f7 100644
--- a/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift
+++ b/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift
@@ -4,7 +4,7 @@ import Foundation
import Perception
import SwiftUI
-public struct AsyncCodeBlock: View { // chat: hid
+public struct AsyncCodeBlock: View {
@State var storage = Storage()
@Environment(\.colorScheme) var colorScheme
@@ -134,7 +134,7 @@ public struct AsyncCodeBlock: View { // chat: hid
// MARK: - Storage
extension AsyncCodeBlock {
- static let queue = DispatchQueue(
+ nonisolated static let queue = DispatchQueue(
label: "code-block-highlight",
qos: .userInteractive,
attributes: .concurrent
@@ -409,6 +409,7 @@ extension AsyncCodeBlock {
let scenario = view.scenario
let brightMode = view.colorScheme != .dark
let droppingLeadingSpaces = view.droppingLeadingSpaces
+ let font = CodeHighlighting.SendableFont(font: view.font)
foregroundColor = view.foregroundColor
if highlightedCode.isEmpty {
@@ -427,7 +428,6 @@ extension AsyncCodeBlock {
highlightTask = Task {
let result = await withUnsafeContinuation { continuation in
AsyncCodeBlock.queue.async {
- let font = view.font
let content = CodeHighlighting.highlighted(
code: code,
language: language,
diff --git a/Tool/Sources/SharedUIComponents/AsyncDiffCodeBlock.swift b/Tool/Sources/SharedUIComponents/AsyncDiffCodeBlock.swift
new file mode 100644
index 00000000..537ead34
--- /dev/null
+++ b/Tool/Sources/SharedUIComponents/AsyncDiffCodeBlock.swift
@@ -0,0 +1,508 @@
+import CodeDiff
+import DebounceFunction
+import Foundation
+import Perception
+import SwiftUI
+
+public struct AsyncDiffCodeBlock: View {
+ @State var storage = Storage()
+ @Environment(\.colorScheme) var colorScheme
+
+ /// If original code is provided, diff will be generated.
+ let originalCode: String?
+ /// The code to present.
+ let code: String
+ /// The language of the code.
+ let language: String
+ /// The index of the first line.
+ let startLineIndex: Int
+ /// The scenario of the code block.
+ let scenario: String
+ /// The font of the code block.
+ let font: NSFont
+ /// The default foreground color of the code block.
+ let proposedForegroundColor: Color?
+ /// Whether to drop common leading spaces of each line.
+ let droppingLeadingSpaces: Bool
+ /// Whether to render the last diff section that only contains removals.
+ let skipLastOnlyRemovalSection: Bool
+
+ public init(
+ code: String,
+ originalCode: String? = nil,
+ language: String,
+ startLineIndex: Int,
+ scenario: String,
+ font: NSFont,
+ droppingLeadingSpaces: Bool,
+ proposedForegroundColor: Color?,
+ ignoreWholeLineChangeInDiff: Bool = true,
+ skipLastOnlyRemovalSection: Bool = false
+ ) {
+ self.code = code
+ self.originalCode = originalCode
+ self.startLineIndex = startLineIndex
+ self.language = language
+ self.scenario = scenario
+ self.font = font
+ self.proposedForegroundColor = proposedForegroundColor
+ self.droppingLeadingSpaces = droppingLeadingSpaces
+ self.skipLastOnlyRemovalSection = skipLastOnlyRemovalSection
+ }
+
+ var foregroundColor: Color {
+ proposedForegroundColor ?? (colorScheme == .dark ? .white : .black)
+ }
+
+ public var body: some View {
+ WithPerceptionTracking {
+ let commonPrecedingSpaceCount = storage.highlightStorage.commonPrecedingSpaceCount
+ VStack(spacing: 0) {
+ lines
+ }
+ .foregroundColor(.white)
+ .font(.init(font))
+ .padding(.leading, 4)
+ .padding(.trailing)
+ .padding(.top, commonPrecedingSpaceCount > 0 ? 16 : 4)
+ .padding(.bottom, 4)
+ .onAppear {
+ storage.highlightStorage.highlight(debounce: false, for: self)
+ storage.diffStorage.diff(for: self)
+ }
+ .onChange(of: code) { code in
+ storage.code = code
+ storage.highlightStorage.highlight(debounce: true, for: self)
+ storage.diffStorage.diff(for: self)
+ }
+ .onChange(of: originalCode) { originalCode in
+ storage.originalCode = originalCode
+ storage.diffStorage.diff(for: self)
+ }
+ .onChange(of: colorScheme) { _ in
+ storage.highlightStorage.highlight(debounce: true, for: self)
+ }
+ .onChange(of: droppingLeadingSpaces) { _ in
+ storage.highlightStorage.highlight(debounce: true, for: self)
+ }
+ .onChange(of: scenario) { _ in
+ storage.highlightStorage.highlight(debounce: true, for: self)
+ }
+ .onChange(of: language) { _ in
+ storage.highlightStorage.highlight(debounce: true, for: self)
+ }
+ .onChange(of: proposedForegroundColor) { _ in
+ storage.highlightStorage.highlight(debounce: true, for: self)
+ }
+ .onChange(of: skipLastOnlyRemovalSection) { _ in
+ storage.skipLastOnlyRemovalSection = skipLastOnlyRemovalSection
+ }
+ }
+ }
+
+ @ViewBuilder
+ var lines: some View {
+ WithPerceptionTracking {
+ let commonPrecedingSpaceCount = storage.highlightStorage.commonPrecedingSpaceCount
+ ForEach(storage.highlightedContent) { line in
+ LineView(
+ isFirstLine: line.id == storage.highlightedContent.first?.id,
+ commonPrecedingSpaceCount: commonPrecedingSpaceCount,
+ line: line,
+ startLineIndex: startLineIndex,
+ foregroundColor: foregroundColor
+ )
+ }
+ }
+ }
+
+ struct LineView: View {
+ let isFirstLine: Bool
+ let commonPrecedingSpaceCount: Int
+ let line: Storage.Line
+ let startLineIndex: Int
+ let foregroundColor: Color
+
+ var body: some View {
+ let attributedString = line.string
+ let lineIndex = line.index + startLineIndex + 1
+ HStack(alignment: .firstTextBaseline, spacing: 4) {
+ Text("\(lineIndex)")
+ .multilineTextAlignment(.trailing)
+ .foregroundColor(foregroundColor.opacity(0.5))
+ .frame(minWidth: 40)
+ Text(AttributedString(attributedString))
+ .foregroundColor(foregroundColor.opacity(0.3))
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .multilineTextAlignment(.leading)
+ .lineSpacing(4)
+ .overlay(alignment: .topLeading) {
+ if isFirstLine, commonPrecedingSpaceCount > 0 {
+ Text("\(commonPrecedingSpaceCount + 1)")
+ .padding(.top, -12)
+ .font(.footnote)
+ .foregroundStyle(foregroundColor)
+ .opacity(0.3)
+ }
+ }
+ }
+ .padding(.vertical, 1)
+ .background(
+ line.kind == .added ? Color.green.opacity(0.2) : line
+ .kind == .deleted ? Color.red.opacity(0.2) : nil
+ )
+ }
+ }
+}
+
+// MARK: - Storage
+
+extension AsyncDiffCodeBlock {
+ nonisolated static let queue = DispatchQueue(
+ label: "code-block-highlight",
+ qos: .userInteractive,
+ attributes: .concurrent
+ )
+
+ public struct DimmedCharacterCount: Equatable {
+ public var prefix: Int
+ public var suffix: Int
+ public init(prefix: Int, suffix: Int) {
+ self.prefix = prefix
+ self.suffix = suffix
+ }
+ }
+
+ @Perceptible
+ class Storage {
+ let diffStorage = DiffStorage()
+ let highlightStorage = HighlightStorage()
+ var skipLastOnlyRemovalSection: Bool = false
+
+ var code: String? {
+ get { highlightStorage.code }
+ set {
+ highlightStorage.code = newValue
+ diffStorage.code = newValue
+ }
+ }
+
+ var originalCode: String? {
+ get { diffStorage.originalCode }
+ set { diffStorage.originalCode = newValue }
+ }
+
+ struct Line: Identifiable {
+ enum Kind {
+ case added
+ case deleted
+ case unchanged
+ }
+
+ let index: Int
+ let kind: Kind
+ let string: NSAttributedString
+
+ var id: String { "\(index)-\(kind)-\(string.string)" }
+ }
+
+ var highlightedContent: [Line] {
+ let commonPrecedingSpaceCount = highlightStorage.commonPrecedingSpaceCount
+ let highlightedCode = highlightStorage.highlightedCode
+ let highlightedOriginalCode = highlightStorage.highlightedOriginalCode
+
+ if let diffResult = diffStorage.diffResult {
+ return Self.presentDiff(
+ new: highlightedCode,
+ original: highlightedOriginalCode,
+ commonPrecedingSpaceCount: commonPrecedingSpaceCount,
+ skipLastOnlyRemovalSection: skipLastOnlyRemovalSection,
+ diffResult: diffResult
+ )
+ }
+
+ return highlightedCode.enumerated().map {
+ Line(index: $0, kind: .unchanged, string: $1)
+ }
+ }
+
+ static func presentDiff(
+ new highlightedCode: [NSAttributedString],
+ original originalHighlightedCode: [NSAttributedString],
+ commonPrecedingSpaceCount: Int,
+ skipLastOnlyRemovalSection: Bool,
+ diffResult: CodeDiff.SnippetDiff
+ ) -> [Line] {
+ var lines = [Line]()
+
+ for (index, section) in diffResult.sections.enumerated() {
+ guard !section.isEmpty else { continue }
+
+ if skipLastOnlyRemovalSection,
+ index == diffResult.sections.count - 1,
+ section.newSnippet.isEmpty
+ {
+ continue
+ }
+
+ for (index, line) in section.oldSnippet.enumerated() {
+ if line.diff == .unchanged { continue }
+ let lineIndex = section.oldOffset + index
+ if lineIndex >= 0, lineIndex < originalHighlightedCode.count {
+ let oldLine = originalHighlightedCode[lineIndex]
+ lines.append(Line(index: lineIndex, kind: .deleted, string: oldLine))
+ }
+ }
+
+ for (index, line) in section.newSnippet.enumerated() {
+ let lineIndex = section.newOffset + index
+ guard lineIndex >= 0, lineIndex < highlightedCode.count else { continue }
+ if line.diff == .unchanged {
+ let newLine = highlightedCode[lineIndex]
+ lines.append(Line(index: lineIndex, kind: .unchanged, string: newLine))
+ } else {
+ let newLine = highlightedCode[lineIndex]
+ lines.append(Line(index: lineIndex, kind: .added, string: newLine))
+ }
+ }
+ }
+
+ return lines
+ }
+ }
+
+ @Perceptible
+ class DiffStorage {
+ private(set) var diffResult: CodeDiff.SnippetDiff?
+
+ @PerceptionIgnored var originalCode: String?
+ @PerceptionIgnored var code: String?
+ @PerceptionIgnored private var diffTask: Task?
+
+ func diff(for view: AsyncDiffCodeBlock) {
+ performDiff(for: view)
+ }
+
+ private func performDiff(for view: AsyncDiffCodeBlock) {
+ diffTask?.cancel()
+ let code = code ?? view.code
+ guard let originalCode = originalCode ?? view.originalCode else {
+ diffResult = nil
+ return
+ }
+
+ diffTask = Task {
+ let result = await withUnsafeContinuation { continuation in
+ AsyncCodeBlock.queue.async {
+ let result = CodeDiff().diff(snippet: code, from: originalCode)
+ continuation.resume(returning: result)
+ }
+ }
+ try Task.checkCancellation()
+ await MainActor.run {
+ diffResult = result
+ }
+ }
+ }
+ }
+
+ @Perceptible
+ class HighlightStorage {
+ private(set) var highlightedOriginalCode = [NSAttributedString]()
+ private(set) var highlightedCode = [NSAttributedString]()
+ private(set) var commonPrecedingSpaceCount = 0
+
+ @PerceptionIgnored var code: String?
+ @PerceptionIgnored var originalCode: String?
+ @PerceptionIgnored private var foregroundColor: Color = .primary
+ @PerceptionIgnored private var debounceFunction: DebounceFunction?
+ @PerceptionIgnored private var highlightTask: Task?
+
+ init() {
+ debounceFunction = .init(duration: 0.1, block: { view in
+ self.highlight(for: view)
+ })
+ }
+
+ func highlight(debounce: Bool, for view: AsyncDiffCodeBlock) {
+ if debounce {
+ Task { @MainActor in await debounceFunction?(view) }
+ } else {
+ highlight(for: view)
+ }
+ }
+
+ private func highlight(for view: AsyncDiffCodeBlock) {
+ highlightTask?.cancel()
+ let code = self.code ?? view.code
+ let originalCode = self.originalCode ?? view.originalCode
+ let language = view.language
+ let scenario = view.scenario
+ let brightMode = view.colorScheme != .dark
+ let droppingLeadingSpaces = view.droppingLeadingSpaces
+ let font = CodeHighlighting.SendableFont(font: view.font)
+ foregroundColor = view.foregroundColor
+
+ if highlightedCode.isEmpty {
+ let content = CodeHighlighting.convertToCodeLines(
+ [.init(string: code), .init(string: originalCode ?? "")],
+ middleDotColor: brightMode
+ ? NSColor.black.withAlphaComponent(0.1)
+ : NSColor.white.withAlphaComponent(0.1),
+ droppingLeadingSpaces: droppingLeadingSpaces,
+ replaceSpacesWithMiddleDots: true
+ )
+ highlightedCode = content.code[0]
+ highlightedOriginalCode = content.code[1]
+ commonPrecedingSpaceCount = content.commonLeadingSpaceCount
+ }
+
+ highlightTask = Task {
+ let result = await withUnsafeContinuation { continuation in
+ AsyncCodeBlock.queue.async {
+ let content = CodeHighlighting.highlighted(
+ code: [code, originalCode ?? ""],
+ language: language,
+ scenario: scenario,
+ brightMode: brightMode,
+ droppingLeadingSpaces: droppingLeadingSpaces,
+ font: font
+ )
+ continuation.resume(returning: content)
+ }
+ }
+ try Task.checkCancellation()
+ await MainActor.run {
+ self.highlightedCode = result.0[0]
+ self.highlightedOriginalCode = result.0[1]
+ self.commonPrecedingSpaceCount = result.1
+ }
+ }
+ }
+ }
+
+ static func limitRange(_ nsRange: NSRange, inside another: NSRange) -> NSRange? {
+ let intersection = NSIntersectionRange(nsRange, another)
+ guard intersection.length > 0 else { return nil }
+ return intersection
+ }
+}
+
+#Preview("Single Line Suggestion") {
+ AsyncDiffCodeBlock(
+ code: " let foo = Bar()",
+ originalCode: " var foo // comment",
+ language: "swift",
+ startLineIndex: 10,
+ scenario: "",
+ font: .monospacedSystemFont(ofSize: 12, weight: .regular),
+ droppingLeadingSpaces: true,
+ proposedForegroundColor: .primary
+ )
+ .frame(width: 400, height: 100)
+}
+
+#Preview("Multiple Line Suggestion") {
+ AsyncDiffCodeBlock(
+ code: " let foo = Bar()\n print(foo)\n print(a)",
+ originalCode: " var foo // comment\n print(bar)\n print(a)",
+ language: "swift",
+ startLineIndex: 10,
+ scenario: "",
+ font: .monospacedSystemFont(ofSize: 12, weight: .regular),
+ droppingLeadingSpaces: true,
+ proposedForegroundColor: .primary
+ )
+ .frame(width: 400, height: 100)
+}
+
+#Preview("Multiple Line Suggestion Including Whole Line Change in Diff") {
+ AsyncDiffCodeBlock(
+ code: "// comment\n let foo = Bar()\n print(bar)\n print(foo)\n",
+ originalCode: " let foo = Bar()\n",
+ language: "swift",
+ startLineIndex: 10,
+ scenario: "",
+ font: .monospacedSystemFont(ofSize: 12, weight: .regular),
+ droppingLeadingSpaces: true,
+ proposedForegroundColor: .primary
+ )
+ .frame(width: 400, height: 100)
+}
+
+#Preview("Updating Content") {
+ struct UpdateContent: View {
+ @State var index = 0
+ struct Case {
+ let code: String
+ let originalCode: String
+ }
+
+ let cases: [Case] = [
+ .init(code: "foo(123)\nprint(foo)", originalCode: "bar(234)\nprint(bar)"),
+ .init(code: "bar(456)", originalCode: "baz(567)"),
+ ]
+
+ var body: some View {
+ VStack {
+ Button("Update") {
+ index = (index + 1) % cases.count
+ }
+ AsyncDiffCodeBlock(
+ code: cases[index].code,
+ originalCode: cases[index].originalCode,
+ language: "swift",
+ startLineIndex: 10,
+ scenario: "",
+ font: .monospacedSystemFont(ofSize: 12, weight: .regular),
+ droppingLeadingSpaces: true,
+ proposedForegroundColor: .primary
+ )
+ }
+ }
+ }
+
+ return UpdateContent()
+ .frame(width: 400, height: 200)
+}
+
+#Preview("Code Diff Editor") {
+ struct V: View {
+ @State var originalCode = ""
+ @State var newCode = ""
+
+ var body: some View {
+ VStack {
+ HStack {
+ VStack {
+ Text("Original")
+ TextEditor(text: $originalCode)
+ .frame(width: 300, height: 200)
+ }
+ VStack {
+ Text("New")
+ TextEditor(text: $newCode)
+ .frame(width: 300, height: 200)
+ }
+ }
+ .font(.body.monospaced())
+ ScrollView {
+ AsyncDiffCodeBlock(
+ code: newCode,
+ originalCode: originalCode,
+ language: "swift",
+ startLineIndex: 0,
+ scenario: "",
+ font: .monospacedSystemFont(ofSize: 12, weight: .regular),
+ droppingLeadingSpaces: true,
+ proposedForegroundColor: .primary
+ )
+ }
+ }
+ .padding()
+ .frame(height: 600)
+ }
+ }
+
+ return V()
+}
diff --git a/Tool/Sources/SharedUIComponents/CopyButton.swift b/Tool/Sources/SharedUIComponents/CopyButton.swift
index 022e84df..f5be3807 100644
--- a/Tool/Sources/SharedUIComponents/CopyButton.swift
+++ b/Tool/Sources/SharedUIComponents/CopyButton.swift
@@ -4,36 +4,60 @@ import SwiftUI
public struct CopyButton: View {
public var copy: () -> Void
@State var isCopied = false
-
+
public init(copy: @escaping () -> Void) {
self.copy = copy
}
-
+
public var body: some View {
- Button(action: {
- withAnimation(.linear(duration: 0.1)) {
- isCopied = true
- }
- copy()
- Task {
- try await Task.sleep(nanoseconds: 1_000_000_000)
- withAnimation(.linear(duration: 0.1)) {
- isCopied = false
- }
+ Image(systemName: isCopied ? "checkmark.circle.fill" : "doc.on.doc.fill")
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 14, height: 14)
+ .frame(width: 20, height: 20, alignment: .center)
+ .foregroundColor(.secondary)
+ .background(
+ .regularMaterial,
+ in: RoundedRectangle(cornerRadius: 4, style: .circular)
+ )
+ .background {
+ RoundedRectangle(cornerRadius: 4, style: .circular)
+ .fill(Color.primary.opacity(0.1))
}
- }) {
- Image(systemName: isCopied ? "checkmark.circle" : "doc.on.doc")
- .resizable()
- .aspectRatio(contentMode: .fit)
- .frame(width: 14, height: 14)
- .frame(width: 20, height: 20, alignment: .center)
- .foregroundColor(.secondary)
- .background(
- .regularMaterial,
- in: RoundedRectangle(cornerRadius: 4, style: .circular)
- )
- .padding(4)
+ .padding(4)
+ .simultaneousGesture(
+ TapGesture()
+ .onEnded { _ in
+ withAnimation(.linear(duration: 0.1)) {
+ isCopied = true
+ }
+ copy()
+ Task {
+ try await Task.sleep(nanoseconds: 1_000_000_000)
+ withAnimation(.linear(duration: 0.1)) {
+ isCopied = false
+ }
+ }
+ }
+ )
+ }
+}
+
+public struct DraggableCopyButton: View {
+ public var content: () -> String
+
+ public init(content: @escaping () -> String) {
+ self.content = content
+ }
+
+ public var body: some View {
+ CopyButton {
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(content(), forType: .string)
+ }
+ .onDrag {
+ NSItemProvider(object: content() as NSString)
}
- .buttonStyle(.borderless)
}
}
+
diff --git a/Tool/Sources/SharedUIComponents/SyntaxHighlighting.swift b/Tool/Sources/SharedUIComponents/SyntaxHighlighting.swift
index 3ec0ea66..2e99b1c7 100644
--- a/Tool/Sources/SharedUIComponents/SyntaxHighlighting.swift
+++ b/Tool/Sources/SharedUIComponents/SyntaxHighlighting.swift
@@ -5,6 +5,29 @@ import SuggestionBasic
import SwiftUI
public enum CodeHighlighting {
+ public struct SendableFont: @unchecked Sendable {
+ public let font: NSFont
+ public init(font: NSFont) {
+ self.font = font
+ }
+ }
+
+ public static func highlightedCodeBlock(
+ code: String,
+ language: String,
+ scenario: String,
+ brightMode: Bool,
+ font: SendableFont
+ ) -> NSAttributedString {
+ highlightedCodeBlock(
+ code: code,
+ language: language,
+ scenario: scenario,
+ brightMode: brightMode,
+ font: font.font
+ )
+ }
+
public static func highlightedCodeBlock(
code: String,
language: String,
@@ -46,6 +69,94 @@ public enum CodeHighlighting {
return formatted
}
+ public static func highlightedCodeBlocks(
+ code: [String],
+ language: String,
+ scenario: String,
+ brightMode: Bool,
+ font: NSFont
+ ) -> [NSAttributedString] {
+ var language = language
+ // Workaround: Highlightr uses a different identifier for Objective-C.
+ if language.lowercased().hasPrefix("objective"), language.lowercased().hasSuffix("c") {
+ language = "objectivec"
+ }
+ func unhighlightedCode(_ code: String) -> NSAttributedString {
+ return NSAttributedString(
+ string: code,
+ attributes: [
+ .foregroundColor: brightMode ? NSColor.black : NSColor.white,
+ .font: font,
+ ]
+ )
+ }
+ guard let highlighter = Highlightr() else {
+ return code.map(unhighlightedCode)
+ }
+ highlighter.setTheme(to: {
+ let mode = brightMode ? "light" : "dark"
+ if scenario.isEmpty {
+ return mode
+ }
+ return "\(scenario)-\(mode)"
+ }())
+ highlighter.theme.setCodeFont(font)
+
+ var formattedCodeBlocks = [NSAttributedString]()
+ for code in code {
+ guard let formatted = highlighter.highlight(code, as: language) else {
+ formattedCodeBlocks.append(unhighlightedCode(code))
+ continue
+ }
+ if formatted.string == "undefined" {
+ formattedCodeBlocks.append(unhighlightedCode(code))
+ continue
+ }
+ formattedCodeBlocks.append(formatted)
+ }
+ return formattedCodeBlocks
+ }
+
+ public static func highlighted(
+ code: String,
+ language: String,
+ scenario: String,
+ brightMode: Bool,
+ droppingLeadingSpaces: Bool,
+ font: SendableFont,
+ replaceSpacesWithMiddleDots: Bool = true
+ ) -> (code: [NSAttributedString], commonLeadingSpaceCount: Int) {
+ highlighted(
+ code: code,
+ language: language,
+ scenario: scenario,
+ brightMode: brightMode,
+ droppingLeadingSpaces: droppingLeadingSpaces,
+ font: font.font,
+ replaceSpacesWithMiddleDots: replaceSpacesWithMiddleDots
+ )
+ }
+
+ public static func highlighted(
+ code: [String],
+ language: String,
+ scenario: String,
+ brightMode: Bool,
+ droppingLeadingSpaces: Bool,
+ font: SendableFont,
+ replaceSpacesWithMiddleDots: Bool = true
+ ) -> (code: [[NSAttributedString]], commonLeadingSpaceCount: Int) {
+ highlighted(
+ code: code,
+ language: language,
+ scenario: scenario,
+ brightMode: brightMode,
+ droppingLeadingSpaces: droppingLeadingSpaces,
+ font: font.font,
+ replaceSpacesWithMiddleDots: replaceSpacesWithMiddleDots
+ )
+ }
+
public static func highlighted(
code: String,
language: String,
@@ -55,7 +166,28 @@ public enum CodeHighlighting {
font: NSFont,
replaceSpacesWithMiddleDots: Bool = true
) -> (code: [NSAttributedString], commonLeadingSpaceCount: Int) {
- let formatted = highlightedCodeBlock(
+ let result = highlighted(
+ code: [code],
+ language: language,
+ scenario: scenario,
+ brightMode: brightMode,
+ droppingLeadingSpaces: droppingLeadingSpaces,
+ font: font,
+ replaceSpacesWithMiddleDots: replaceSpacesWithMiddleDots
+ )
+ return (result.code.first ?? [], result.commonLeadingSpaceCount)
+ }
+
+ public static func highlighted(
+ code: [String],
+ language: String,
+ scenario: String,
+ brightMode: Bool,
+ droppingLeadingSpaces: Bool,
+ font: NSFont,
+ replaceSpacesWithMiddleDots: Bool = true
+ ) -> (code: [[NSAttributedString]], commonLeadingSpaceCount: Int) {
+ let formatted = highlightedCodeBlocks(
code: code,
language: language,
scenario: scenario,
@@ -79,7 +211,23 @@ public enum CodeHighlighting {
droppingLeadingSpaces: Bool,
replaceSpacesWithMiddleDots: Bool = true
) -> (code: [NSAttributedString], commonLeadingSpaceCount: Int) {
- let input = formattedCode.string
+ let result = convertToCodeLines(
+ [formattedCode],
+ middleDotColor: middleDotColor,
+ droppingLeadingSpaces: droppingLeadingSpaces,
+ replaceSpacesWithMiddleDots: replaceSpacesWithMiddleDots
+ )
+ return (result.code.first ?? [], result.commonLeadingSpaceCount)
+ }
+
+ public static func convertToCodeLines(
+ _ formattedCode: [NSAttributedString],
+ middleDotColor: NSColor,
+ droppingLeadingSpaces: Bool,
+ replaceSpacesWithMiddleDots: Bool = true
+ ) -> (code: [[NSAttributedString]], commonLeadingSpaceCount: Int) {
+ let inputs = formattedCode.map { $0.string }
+
func isEmptyLine(_ line: String) -> Bool {
if line.isEmpty { return true }
guard let regex = try? NSRegularExpression(pattern: #"^\s*\n?$"#) else { return false }
@@ -93,11 +241,13 @@ public enum CodeHighlighting {
return false
}
- let separatedInput = input.splitByNewLine(omittingEmptySubsequences: false)
+ let separatedInputs = inputs.map { $0.splitByNewLine(omittingEmptySubsequences: false)
.map { String($0) }
+ }
+
let commonLeadingSpaceCount = {
if !droppingLeadingSpaces { return 0 }
- let split = separatedInput
+ let split = separatedInputs.flatMap { $0 }
var result = 0
outerLoop: for i in stride(from: 40, through: 4, by: -4) {
for line in split {
@@ -110,50 +260,54 @@ public enum CodeHighlighting {
}
return result
}()
- var output = [NSAttributedString]()
- var start = 0
- for sub in separatedInput {
- let range = NSMakeRange(start, sub.utf16.count)
- let attributedString = formattedCode.attributedSubstring(from: range)
- let mutable = NSMutableAttributedString(attributedString: attributedString)
-
- // remove leading spaces
- if commonLeadingSpaceCount > 0 {
- let leadingSpaces = String(repeating: " ", count: commonLeadingSpaceCount)
- if mutable.string.hasPrefix(leadingSpaces) {
- mutable.replaceCharacters(
- in: NSRange(location: 0, length: commonLeadingSpaceCount),
- with: ""
- )
- } else if isEmptyLine(mutable.string) {
- mutable.mutableString.setString("")
- }
- }
+ var outputs = [[NSAttributedString]]()
+ for (separatedInput, formattedCode) in zip(separatedInputs, formattedCode) {
+ var output = [NSAttributedString]()
+ var start = 0
+ for sub in separatedInput {
+ let range = NSMakeRange(start, sub.utf16.count)
+ let attributedString = formattedCode.attributedSubstring(from: range)
+ let mutable = NSMutableAttributedString(attributedString: attributedString)
- if replaceSpacesWithMiddleDots {
- // use regex to replace all spaces to a middle dot
- do {
- let regex = try NSRegularExpression(pattern: "[ ]*", options: [])
- let result = regex.matches(
- in: mutable.string,
- range: NSRange(location: 0, length: mutable.mutableString.length)
- )
- for r in result {
- let range = r.range
+ // remove leading spaces
+ if commonLeadingSpaceCount > 0 {
+ let leadingSpaces = String(repeating: " ", count: commonLeadingSpaceCount)
+ if mutable.string.hasPrefix(leadingSpaces) {
mutable.replaceCharacters(
- in: range,
- with: String(repeating: "·", count: range.length)
+ in: NSRange(location: 0, length: commonLeadingSpaceCount),
+ with: ""
)
- mutable.addAttributes([
- .foregroundColor: middleDotColor,
- ], range: range)
+ } else if isEmptyLine(mutable.string) {
+ mutable.mutableString.setString("")
}
- } catch {}
+ }
+
+ if replaceSpacesWithMiddleDots {
+ // use regex to replace all spaces to a middle dot
+ do {
+ let regex = try NSRegularExpression(pattern: "[ ]*", options: [])
+ let result = regex.matches(
+ in: mutable.string,
+ range: NSRange(location: 0, length: mutable.mutableString.length)
+ )
+ for r in result {
+ let range = r.range
+ mutable.replaceCharacters(
+ in: range,
+ with: String(repeating: "·", count: range.length)
+ )
+ mutable.addAttributes([
+ .foregroundColor: middleDotColor,
+ ], range: range)
+ }
+ } catch {}
+ }
+ output.append(mutable)
+ start += range.length + 1
}
- output.append(mutable)
- start += range.length + 1
+ outputs.append(output)
}
- return (output, commonLeadingSpaceCount)
+ return (outputs, commonLeadingSpaceCount)
}
}
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
new file mode 100644
index 00000000..d3f715ea
--- /dev/null
+++ b/Tool/Sources/SharedUIComponents/XcodeStyleFrame.swift
@@ -0,0 +1,42 @@
+import Foundation
+import SwiftUI
+
+public struct XcodeLikeFrame: View {
+ @Environment(\.colorScheme) var colorScheme
+ let content: Content
+ let cornerRadius: Double
+
+ public init(cornerRadius: Double, content: Content) {
+ self.content = content
+ self.cornerRadius = cornerRadius
+ }
+
+ public var body: some View {
+ content
+ .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
+ .background(
+ RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
+ .fill(Material.bar)
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: max(0, cornerRadius), style: .continuous)
+ .stroke(Color.black.opacity(0.1), style: .init(lineWidth: 1))
+ ) // 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.1), style: .init(lineWidth: 1))
+ .padding(1)
+ )
+ }
+}
+
+public extension View {
+ func xcodeStyleFrame(cornerRadius: Double? = nil) -> some View {
+ if #available(macOS 26.0, *) {
+ XcodeLikeFrame(cornerRadius: cornerRadius ?? 14, content: self)
+ } else {
+ XcodeLikeFrame(cornerRadius: cornerRadius ?? 10, content: self)
+ }
+ }
+}
+
diff --git a/Tool/Sources/SuggestionBasic/EditorInformation.swift b/Tool/Sources/SuggestionBasic/EditorInformation.swift
index 8518b8b0..38d6e26d 100644
--- a/Tool/Sources/SuggestionBasic/EditorInformation.swift
+++ b/Tool/Sources/SuggestionBasic/EditorInformation.swift
@@ -1,14 +1,14 @@
import Foundation
import Parsing
-public struct EditorInformation {
- public struct LineAnnotation {
+public struct EditorInformation: Sendable {
+ public struct LineAnnotation: Sendable {
public var type: String
public var line: Int
public var message: String
}
- public struct SourceEditorContent {
+ public struct SourceEditorContent: Sendable {
/// The content of the source editor.
public var content: String
/// The content of the source editor in lines. Every line should ends with `\n`.
@@ -23,7 +23,9 @@ public struct EditorInformation {
public var lineAnnotations: [LineAnnotation]
public var selectedContent: String {
+ guard !lines.isEmpty else { return "" }
if let range = selections.first {
+ if range.isEmpty { return "" }
let startIndex = min(
max(0, range.start.line),
lines.endIndex - 1
@@ -103,8 +105,12 @@ public struct EditorInformation {
inside range: CursorRange,
ignoreColumns: Bool = false
) -> (code: String, lines: [String]) {
- guard range.start <= range.end else { return ("", []) }
-
+ if range.start == range.end {
+ // Empty selection (cursor only): return empty code but include the containing line
+ return ("", lines(in: code, containing: range))
+ }
+ guard range.start < range.end else { return ("", []) }
+
let rangeLines = lines(in: code, containing: range)
if ignoreColumns {
return (rangeLines.joined(), rangeLines)
diff --git a/Tool/Sources/SuggestionBasic/ExportedFromLSP.swift b/Tool/Sources/SuggestionBasic/ExportedFromLSP.swift
index f4345fd0..52983a6b 100644
--- a/Tool/Sources/SuggestionBasic/ExportedFromLSP.swift
+++ b/Tool/Sources/SuggestionBasic/ExportedFromLSP.swift
@@ -1,4 +1,4 @@
-import LanguageServerProtocol
+@preconcurrency import LanguageServerProtocol
/// Line starts at 0.
public typealias CursorPosition = LanguageServerProtocol.Position
diff --git a/Tool/Sources/SuggestionBasic/LanguageIdentifierFromFilePath.swift b/Tool/Sources/SuggestionBasic/LanguageIdentifierFromFilePath.swift
index a0478833..cc57246d 100644
--- a/Tool/Sources/SuggestionBasic/LanguageIdentifierFromFilePath.swift
+++ b/Tool/Sources/SuggestionBasic/LanguageIdentifierFromFilePath.swift
@@ -1,7 +1,7 @@
import Foundation
-import LanguageServerProtocol
+@preconcurrency import LanguageServerProtocol
-public enum CodeLanguage: RawRepresentable, Codable, CaseIterable, Hashable {
+public enum CodeLanguage: RawRepresentable, Codable, CaseIterable, Hashable, Sendable {
case builtIn(LanguageIdentifier)
case plaintext
case other(String)
diff --git a/Tool/Sources/SuggestionInjector/SuggestionInjector.swift b/Tool/Sources/SuggestionInjector/SuggestionInjector.swift
index 00b817d0..6a6af8a2 100644
--- a/Tool/Sources/SuggestionInjector/SuggestionInjector.swift
+++ b/Tool/Sources/SuggestionInjector/SuggestionInjector.swift
@@ -216,12 +216,12 @@ public struct SuggestionInjector {
public struct SuggestionAnalyzer {
struct Result {
- enum InsertPostion {
+ enum InsertPosition {
case currentLine
case nextLine
}
- var insertPosition: InsertPostion
+ var insertPosition: InsertPosition
var commonPrefix: String?
}
diff --git a/Tool/Sources/SuggestionProvider/SuggestionServiceMiddleware.swift b/Tool/Sources/SuggestionProvider/SuggestionServiceMiddleware.swift
index f26492c1..7d08aeaa 100644
--- a/Tool/Sources/SuggestionProvider/SuggestionServiceMiddleware.swift
+++ b/Tool/Sources/SuggestionProvider/SuggestionServiceMiddleware.swift
@@ -45,6 +45,13 @@ public enum SuggestionServiceMiddlewareContainer {
public struct DisabledLanguageSuggestionServiceMiddleware: SuggestionServiceMiddleware {
public init() {}
+
+ struct DisabledLanguageError: Error, LocalizedError {
+ let language: String
+ var errorDescription: String? {
+ "Suggestion service is disabled for \(language)."
+ }
+ }
public func getSuggestion(
_ request: SuggestionRequest,
@@ -55,10 +62,7 @@ public struct DisabledLanguageSuggestionServiceMiddleware: SuggestionServiceMidd
if UserDefaults.shared.value(for: \.suggestionFeatureDisabledLanguageList)
.contains(where: { $0 == language.rawValue })
{
- #if DEBUG
- Logger.service.info("Suggestion service is disabled for \(language).")
- #endif
- return []
+ throw DisabledLanguageError(language: language.rawValue)
}
return try await next(request)
diff --git a/Tool/Sources/Terminal/Terminal.swift b/Tool/Sources/Terminal/Terminal.swift
index 89812c4b..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],
@@ -71,7 +78,8 @@ public final class Terminal: TerminalType, @unchecked Sendable {
continuation = cont
}
- Task { [continuation, self] in
+ Task { [continuation, process, self] in
+ _ = self
let notificationCenter = NotificationCenter.default
let notifications = notificationCenter.notifications(
named: FileHandle.readCompletionNotification,
@@ -79,26 +87,33 @@ public final class Terminal: TerminalType, @unchecked Sendable {
)
for await notification in notifications {
let userInfo = notification.userInfo
+ guard let object = notification.object as? FileHandle,
+ object === outputPipe.fileHandleForReading
+ else {
+ continue
+ }
if let data = userInfo?[NSFileHandleNotificationDataItem] as? Data,
let content = String(data: data, encoding: .utf8),
!content.isEmpty
{
continuation?.yield(content)
}
- if !(self.process?.isRunning ?? false) {
- let reason = self.process?.terminationReason ?? .exit
- let status = self.process?.terminationStatus ?? 1
- if let output = (self.process?.standardOutput as? Pipe)?.fileHandleForReading
- .readDataToEndOfFile(),
- let content = String(data: output, encoding: .utf8),
+ if !process.isRunning {
+ if let fileHandle = (process.standardOutput as? Pipe)?
+ .fileHandleForReading,
+ let data = try? fileHandle.readToEnd(),
+ let content = String(data: data, encoding: .utf8),
!content.isEmpty
{
continuation?.yield(content)
}
+ let status = process.terminationStatus
+
if status == 0 {
continuation?.finish()
} else {
+ let reason = process.terminationReason
continuation?.finish(throwing: TerminationError(
reason: reason,
status: status
@@ -125,6 +140,46 @@ public final class Terminal: TerminalType, @unchecked Sendable {
return contentStream
}
+ public func streamLineForCommand(
+ _ command: String = "/bin/bash",
+ arguments: [String],
+ currentDirectoryURL: URL? = nil,
+ environment: [String: String]
+ ) -> AsyncThrowingStream {
+ let chunkStream = streamCommand(
+ command,
+ arguments: arguments,
+ currentDirectoryURL: currentDirectoryURL,
+ environment: environment
+ )
+
+ return AsyncThrowingStream { continuation in
+ Task {
+ var buffer = ""
+ do {
+ for try await chunk in chunkStream {
+ buffer.append(chunk)
+
+ while let range = buffer.range(of: "\n") {
+ let line = String(buffer[..] = [:]
+
public init(messages: [Message]) {
self.messages = messages
}
@@ -92,30 +95,54 @@ public class ToastController: ObservableObject {
buttons: [Message.MessageButton] = [],
duration: TimeInterval = 4
) {
- let id = UUID()
- let message = Message(
- id: id,
- type: type,
- namespace: namespace,
- content: Text(content),
- buttons: buttons.map { b in
- Message.MessageButton(label: b.label, action: { [weak self] in
- b.action()
+ Task { @MainActor in
+ // Find existing message with same content and type (and namespace)
+ if let existingIndex = messages.firstIndex(where: {
+ $0.type == type &&
+ $0.content == Text(content) &&
+ $0.namespace == namespace
+ }) {
+ let existingMessage = messages[existingIndex]
+ // Cancel previous removal task
+ removalTasks[existingMessage.id]?.cancel()
+ // Start new removal task for this message
+ removalTasks[existingMessage.id] = Task { @MainActor in
+ try await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000))
withAnimation(.easeInOut(duration: 0.2)) {
- self?.messages.removeAll { $0.id == id }
+ messages.removeAll { $0.id == existingMessage.id }
}
- })
+ removalTasks.removeValue(forKey: existingMessage.id)
+ }
+ return
}
- )
-
- Task { @MainActor in
+
+ let id = UUID()
+ let message = Message(
+ id: id,
+ type: type,
+ namespace: namespace,
+ content: Text(content),
+ buttons: buttons.map { b in
+ Message.MessageButton(label: b.label, action: { [weak self] in
+ b.action()
+ withAnimation(.easeInOut(duration: 0.2)) {
+ self?.messages.removeAll { $0.id == id }
+ }
+ })
+ }
+ )
+
withAnimation(.easeInOut(duration: 0.2)) {
messages.append(message)
messages = messages.suffix(3)
}
- try await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000))
- withAnimation(.easeInOut(duration: 0.2)) {
- messages.removeAll { $0.id == id }
+
+ removalTasks[id] = Task { @MainActor in
+ try await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000))
+ withAnimation(.easeInOut(duration: 0.2)) {
+ messages.removeAll { $0.id == id }
+ }
+ removalTasks.removeValue(forKey: id)
}
}
}
@@ -177,4 +204,3 @@ public struct Toast {
}
}
}
-
diff --git a/Tool/Sources/WebScrapper/WebScrapper.swift b/Tool/Sources/WebScrapper/WebScrapper.swift
new file mode 100644
index 00000000..e7c45725
--- /dev/null
+++ b/Tool/Sources/WebScrapper/WebScrapper.swift
@@ -0,0 +1,161 @@
+import Foundation
+import SwiftSoup
+import WebKit
+
+@MainActor
+public final class WebScrapper {
+ final class NavigationDelegate: NSObject, WKNavigationDelegate {
+ weak var scrapper: WebScrapper?
+
+ public nonisolated func webView(_ webView: WKWebView, didFinish _: WKNavigation!) {
+ Task { @MainActor in
+ let scrollToBottomScript = "window.scrollTo(0, document.body.scrollHeight);"
+ _ = try? await webView.evaluateJavaScript(scrollToBottomScript)
+ self.scrapper?.webViewDidFinishLoading = true
+ }
+ }
+
+ public nonisolated func webView(
+ _: WKWebView,
+ didFail _: WKNavigation!,
+ withError error: Error
+ ) {
+ Task { @MainActor in
+ self.scrapper?.navigationError = error
+ self.scrapper?.webViewDidFinishLoading = true
+ }
+ }
+ }
+
+ public var webView: WKWebView
+
+ var webViewDidFinishLoading = false
+ var navigationError: (any Error)?
+ let navigationDelegate: NavigationDelegate = .init()
+
+ enum WebScrapperError: Error {
+ case retry
+ }
+
+ public init() async {
+ let jsonRuleList = ###"""
+ [
+ {
+ "trigger": {
+ "url-filter": ".*",
+ "resource-type": ["font"]
+ },
+ "action": {
+ "type": "block"
+ }
+ },
+ {
+ "trigger": {
+ "url-filter": ".*",
+ "resource-type": ["image"]
+ },
+ "action": {
+ "type": "block"
+ }
+ },
+ {
+ "trigger": {
+ "url-filter": ".*",
+ "resource-type": ["media"]
+ },
+ "action": {
+ "type": "block"
+ }
+ }
+ ]
+ """###
+
+ let list = try? await WKContentRuleListStore.default().compileContentRuleList(
+ forIdentifier: "web-scrapping",
+ encodedContentRuleList: jsonRuleList
+ )
+
+ let configuration = WKWebViewConfiguration()
+ if let list {
+ configuration.userContentController.add(list)
+ }
+ configuration.allowsAirPlayForMediaPlayback = false
+ configuration.mediaTypesRequiringUserActionForPlayback = .all
+ configuration.defaultWebpagePreferences.preferredContentMode = .desktop
+ configuration.defaultWebpagePreferences.allowsContentJavaScript = true
+ configuration.websiteDataStore = .nonPersistent()
+ configuration.applicationNameForUserAgent =
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0.1 Safari/605.1.15"
+
+ if #available(iOS 17.0, macOS 14.0, *) {
+ configuration.allowsInlinePredictions = false
+ }
+
+ // The web page need the web view to have a size to load correctly.
+ let webView = WKWebView(
+ frame: .init(x: 0, y: 0, width: 800, height: 5000),
+ configuration: configuration
+ )
+ self.webView = webView
+ navigationDelegate.scrapper = self
+ webView.navigationDelegate = navigationDelegate
+ }
+
+ public func fetch(
+ url: URL,
+ validate: @escaping (SwiftSoup.Document) -> Bool = { _ in true },
+ timeout: TimeInterval = 15,
+ retryLimit: Int = 50
+ ) async throws -> String {
+ webViewDidFinishLoading = false
+ navigationError = nil
+ var retryCount = 0
+ _ = webView.load(.init(url: url))
+ while !webViewDidFinishLoading {
+ try await Task.sleep(nanoseconds: 10_000_000)
+ }
+ let deadline = Date().addingTimeInterval(timeout)
+ if let navigationError { throw navigationError }
+ while retryCount < retryLimit, Date() < deadline {
+ if let html = try? await getHTML(), !html.isEmpty,
+ let document = try? SwiftSoup.parse(html, url.path),
+ validate(document)
+ {
+ return html
+ }
+ retryCount += 1
+ try await Task.sleep(nanoseconds: 100_000_000)
+ }
+
+ enum Error: Swift.Error, LocalizedError {
+ case failToValidate
+
+ var errorDescription: String? {
+ switch self {
+ case .failToValidate:
+ return "Failed to validate the HTML content within the given timeout and retry limit."
+ }
+ }
+ }
+ throw Error.failToValidate
+ }
+
+ func getHTML() async throws -> String {
+ do {
+ let isReady = try await webView.evaluateJavaScript(checkIfReady) as? Bool ?? false
+ if !isReady { throw WebScrapperError.retry }
+ return try await webView.evaluateJavaScript(getHTMLText) as? String ?? ""
+ } catch {
+ throw WebScrapperError.retry
+ }
+ }
+}
+
+private let getHTMLText = """
+document.documentElement.outerHTML;
+"""
+
+private let checkIfReady = """
+document.readyState === "ready" || document.readyState === "complete";
+"""
+
diff --git a/Tool/Sources/WebSearchService/SearchServices/AppleDocumentationSearchService.swift b/Tool/Sources/WebSearchService/SearchServices/AppleDocumentationSearchService.swift
new file mode 100644
index 00000000..680c4fb6
--- /dev/null
+++ b/Tool/Sources/WebSearchService/SearchServices/AppleDocumentationSearchService.swift
@@ -0,0 +1,60 @@
+import Foundation
+import SwiftSoup
+import WebKit
+import WebScrapper
+
+struct AppleDocumentationSearchService: SearchService {
+ func search(query: String) async throws -> WebSearchResult {
+ let queryEncoded = query
+ .addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""
+ let url = URL(string: "https://developer.apple.com/search/?q=\(queryEncoded)")!
+
+ let scrapper = await WebScrapper()
+ let html = try await scrapper.fetch(url: url) { document in
+ DeveloperDotAppleResultParser.validate(document: document)
+ }
+
+ return try DeveloperDotAppleResultParser.parse(html: html)
+ }
+}
+
+enum DeveloperDotAppleResultParser {
+ static func validate(document: SwiftSoup.Document) -> Bool {
+ guard let _ = try? document.select("ul.search-results").first
+ else { return false }
+ return true
+ }
+
+ static func parse(html: String) throws -> WebSearchResult {
+ let document = try SwiftSoup.parse(html)
+ let searchResult = try? document.select("ul.search-results").first
+
+ guard let searchResult else { return .init(webPages: []) }
+
+ var results: [WebSearchResult.WebPage] = []
+ for element in searchResult.children() {
+ if let titleElement = try? element.select("p.result-title"),
+ let link = try? titleElement.select("a").attr("href"),
+ !link.isEmpty
+ {
+ let title = (try? titleElement.text()) ?? ""
+ let snippet = (try? element.select("p.result-description").text())
+ ?? (try? element.select("ul.breadcrumb-list").text())
+ ?? ""
+ results.append(WebSearchResult.WebPage(
+ urlString: {
+ if link.hasPrefix("/") {
+ return "https://developer.apple.com\(link)"
+ }
+ return link
+ }(),
+ title: title,
+ snippet: snippet
+ ))
+ }
+ }
+
+ return WebSearchResult(webPages: results)
+ }
+}
+
diff --git a/Tool/Sources/BingSearchService/BingSearchService.swift b/Tool/Sources/WebSearchService/SearchServices/BingSearchService.swift
similarity index 64%
rename from Tool/Sources/BingSearchService/BingSearchService.swift
rename to Tool/Sources/WebSearchService/SearchServices/BingSearchService.swift
index 4cc4b88c..0f373168 100644
--- a/Tool/Sources/BingSearchService/BingSearchService.swift
+++ b/Tool/Sources/WebSearchService/SearchServices/BingSearchService.swift
@@ -1,19 +1,19 @@
import Foundation
-public struct BingSearchResult: Codable {
- public var webPages: WebPages
+struct BingSearchResult: Codable {
+ var webPages: WebPages
- public struct WebPages: Codable {
- public var webSearchUrl: String
- public var totalEstimatedMatches: Int
- public var value: [WebPageValue]
+ struct WebPages: Codable {
+ var webSearchUrl: String
+ var totalEstimatedMatches: Int
+ var value: [WebPageValue]
- public struct WebPageValue: Codable {
- public var id: String
- public var name: String
- public var url: String
- public var displayUrl: String
- public var snippet: String
+ struct WebPageValue: Codable {
+ var id: String
+ var name: String
+ var url: String
+ var displayUrl: String
+ var snippet: String
}
}
}
@@ -37,21 +37,32 @@ enum BingSearchError: Error, LocalizedError {
case let .searchURLFormatIncorrect(url):
return "The search URL format is incorrect: \(url)"
case .subscriptionKeyNotAvailable:
- return "The I didn't provide a subscription key to use Bing search."
+ return "Bing search subscription key is not available, please set it up in the service tab."
}
}
}
-public struct BingSearchService {
- public var subscriptionKey: String
- public var searchURL: String
+struct BingSearchService: SearchService {
+ var subscriptionKey: String
+ var searchURL: String
- public init(subscriptionKey: String, searchURL: String) {
+ init(subscriptionKey: String, searchURL: String) {
self.subscriptionKey = subscriptionKey
self.searchURL = searchURL
}
- public func search(
+ func search(query: String) async throws -> WebSearchResult {
+ let result = try await search(query: query, numberOfResult: 10)
+ return WebSearchResult(webPages: result.webPages.value.map {
+ WebSearchResult.WebPage(
+ urlString: $0.url,
+ title: $0.name,
+ snippet: $0.snippet
+ )
+ })
+ }
+
+ func search(
query: String,
numberOfResult: Int,
freshness: String? = nil
diff --git a/Tool/Sources/WebSearchService/SearchServices/HeadlessBrowserSearchService.swift b/Tool/Sources/WebSearchService/SearchServices/HeadlessBrowserSearchService.swift
new file mode 100644
index 00000000..949004ca
--- /dev/null
+++ b/Tool/Sources/WebSearchService/SearchServices/HeadlessBrowserSearchService.swift
@@ -0,0 +1,285 @@
+import Foundation
+import SwiftSoup
+import WebKit
+import WebScrapper
+
+struct HeadlessBrowserSearchService: SearchService {
+ let engine: WebSearchProvider.HeadlessBrowserEngine
+
+ func search(query: String) async throws -> WebSearchResult {
+ let queryEncoded = query
+ .addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""
+ let url = switch engine {
+ case .google:
+ URL(string: "https://www.google.com/search?q=\(queryEncoded)")!
+ case .baidu:
+ URL(string: "https://www.baidu.com/s?wd=\(queryEncoded)")!
+ case .duckDuckGo:
+ URL(string: "https://duckduckgo.com/?q=\(queryEncoded)")!
+ case .bing:
+ URL(string: "https://www.bing.com/search?q=\(queryEncoded)")!
+ }
+
+ let scrapper = await WebScrapper()
+ let html = try await scrapper.fetch(url: url) { document in
+ switch engine {
+ case .google:
+ return GoogleSearchResultParser.validate(document: document)
+ case .baidu:
+ return BaiduSearchResultParser.validate(document: document)
+ case .duckDuckGo:
+ return DuckDuckGoSearchResultParser.validate(document: document)
+ case .bing:
+ return BingSearchResultParser.validate(document: document)
+ }
+ }
+
+ switch engine {
+ case .google:
+ return try GoogleSearchResultParser.parse(html: html)
+ case .baidu:
+ return await BaiduSearchResultParser.parse(html: html)
+ case .duckDuckGo:
+ return DuckDuckGoSearchResultParser.parse(html: html)
+ case .bing:
+ return BingSearchResultParser.parse(html: html)
+ }
+ }
+}
+
+enum GoogleSearchResultParser {
+ static func validate(document: SwiftSoup.Document) -> Bool {
+ guard let _ = try? document.select("#rso").first
+ else { return false }
+ return true
+ }
+
+ static func parse(html: String) throws -> WebSearchResult {
+ let document = try SwiftSoup.parse(html)
+ let searchResult = try document.select("#rso").first
+
+ guard let searchResult else { return .init(webPages: []) }
+
+ var results: [WebSearchResult.WebPage] = []
+ for element in searchResult.children() {
+ if let title = try? element.select("h3").text(),
+ let link = try? element.select("a").attr("href"),
+ !link.isEmpty,
+ // A magic class name.
+ let snippet = try? element.select("div.VwiC3b").first()?.text()
+ ?? element.select("span.st").first()?.text()
+ {
+ results.append(WebSearchResult.WebPage(
+ urlString: link,
+ title: title,
+ snippet: snippet
+ ))
+ }
+ }
+
+ return WebSearchResult(webPages: results)
+ }
+}
+
+enum BaiduSearchResultParser {
+ static func validate(document: SwiftSoup.Document) -> Bool {
+ return (try? document.select("#content_left").first()) != nil
+ }
+
+ static func getRealLink(from baiduLink: String) async -> String {
+ guard let url = URL(string: baiduLink) else {
+ return baiduLink
+ }
+
+ let config = URLSessionConfiguration.default
+ config.httpShouldSetCookies = true
+ config.httpCookieAcceptPolicy = .always
+
+ var request = URLRequest(url: url)
+ request.httpMethod = "GET"
+ request.addValue(
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15",
+ forHTTPHeaderField: "User-Agent"
+ )
+
+ let redirectCapturer = RedirectCapturer()
+ let session = URLSession(
+ configuration: config,
+ delegate: redirectCapturer,
+ delegateQueue: nil
+ )
+
+ do {
+ let _ = try await session.data(for: request)
+
+ if let finalURL = redirectCapturer.finalURL {
+ return finalURL.absoluteString
+ }
+
+ return baiduLink
+ } catch {
+ return baiduLink
+ }
+ }
+
+ class RedirectCapturer: NSObject, URLSessionTaskDelegate {
+ var finalURL: URL?
+
+ func urlSession(
+ _ session: URLSession,
+ task: URLSessionTask,
+ willPerformHTTPRedirection response: HTTPURLResponse,
+ newRequest request: URLRequest,
+ completionHandler: @escaping (URLRequest?) -> Void
+ ) {
+ finalURL = request.url
+ completionHandler(request)
+ }
+ }
+
+ static func parse(html: String) async -> WebSearchResult {
+ let document = try? SwiftSoup.parse(html)
+ let elements = try? document?.select("#content_left").first()?.children()
+
+ var results: [WebSearchResult.WebPage] = []
+ if let elements = elements {
+ for element in elements {
+ if let titleElement = try? element.select("h3").first(),
+ let link = try? element.select("a").attr("href"),
+ link.hasPrefix("http")
+ {
+ let realLink = await getRealLink(from: link)
+ let title = (try? titleElement.text()) ?? ""
+ let snippet = {
+ let abstract = try? element.select("div[data-module=\"abstract\"]").text()
+ if let abstract, !abstract.isEmpty {
+ return abstract
+ }
+ return (try? titleElement.nextElementSibling()?.text()) ?? ""
+ }()
+ results.append(WebSearchResult.WebPage(
+ urlString: realLink,
+ title: title,
+ snippet: snippet
+ ))
+ }
+ }
+ }
+
+ return WebSearchResult(webPages: results)
+ }
+}
+
+enum DuckDuckGoSearchResultParser {
+ static func validate(document: SwiftSoup.Document) -> Bool {
+ return (try? document.select(".react-results--main").first()) != nil
+ }
+
+ static func parse(html: String) -> WebSearchResult {
+ let document = try? SwiftSoup.parse(html)
+ let body = document?.body()
+
+ var results: [WebSearchResult.WebPage] = []
+
+ if let reactResults = try? body?.select(".react-results--main") {
+ for object in reactResults {
+ for element in object.children() {
+ if let linkElement = try? element.select("a[data-testid=\"result-title-a\"]"),
+ let link = try? linkElement.attr("href"),
+ link.hasPrefix("http"),
+ let titleElement = try? element.select("span").first()
+ {
+ let title = (try? titleElement.select("span").first()?.text()) ?? ""
+ let snippet = (
+ try? element.select("[data-result=snippet]").first()?.text()
+ ) ?? ""
+
+ results.append(WebSearchResult.WebPage(
+ urlString: link,
+ title: title,
+ snippet: snippet
+ ))
+ }
+ }
+ }
+ }
+
+ return WebSearchResult(webPages: results)
+ }
+}
+
+enum BingSearchResultParser {
+ static func validate(document: SwiftSoup.Document) -> Bool {
+ return (try? document.select("#b_results").first()) != nil
+ }
+
+ static func getRealLink(from bingLink: String) -> String {
+ guard let url = URL(string: bingLink) else { return bingLink }
+
+ if let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
+ let queryItems = components.queryItems,
+ var uParam = queryItems.first(where: { $0.name == "u" })?.value
+ {
+ if uParam.hasPrefix("a1aHR") {
+ uParam.removeFirst()
+ uParam.removeFirst()
+ }
+
+ func decode() -> String? {
+ guard let decodedData = Data(base64Encoded: uParam),
+ let decodedString = String(data: decodedData, encoding: .utf8)
+ else { return nil }
+ return decodedString
+ }
+
+ if let decodedString = decode() {
+ return decodedString
+ }
+ uParam += "="
+ if let decodedString = decode() {
+ return decodedString
+ }
+ uParam += "="
+ if let decodedString = decode() {
+ return decodedString
+ }
+ }
+
+ return bingLink
+ }
+
+ static func parse(html: String) -> WebSearchResult {
+ let document = try? SwiftSoup.parse(html)
+ let searchResults = try? document?.select("#b_results").first()
+
+ var results: [WebSearchResult.WebPage] = []
+ if let elements = try? searchResults?.select("li.b_algo") {
+ for element in elements {
+ if let titleElement = try? element.select("h2").first(),
+ let linkElement = try? titleElement.select("a").first(),
+ let link = try? linkElement.attr("href"),
+ link.hasPrefix("http")
+ {
+ let link = getRealLink(from: link)
+ let title = (try? titleElement.text()) ?? ""
+ let snippet = {
+ if let it = try? element.select(".b_caption p").first()?.text(),
+ !it.isEmpty { return it }
+ if let it = try? element.select(".b_lineclamp2").first()?.text(),
+ !it.isEmpty { return it }
+ return (try? element.select("p").first()?.text()) ?? ""
+ }()
+
+ results.append(WebSearchResult.WebPage(
+ urlString: link,
+ title: title,
+ snippet: snippet
+ ))
+ }
+ }
+ }
+
+ return WebSearchResult(webPages: results)
+ }
+}
+
diff --git a/Tool/Sources/WebSearchService/SearchServices/SerpAPISearchService.swift b/Tool/Sources/WebSearchService/SearchServices/SerpAPISearchService.swift
new file mode 100644
index 00000000..0fa7a1ee
--- /dev/null
+++ b/Tool/Sources/WebSearchService/SearchServices/SerpAPISearchService.swift
@@ -0,0 +1,67 @@
+import Foundation
+
+struct SerpAPIResponse: Codable {
+ var organic_results: [OrganicResult]
+
+ struct OrganicResult: Codable {
+ var position: Int?
+ var title: String?
+ var link: String?
+ var snippet: String?
+
+ func toWebSearchResult() -> WebSearchResult.WebPage? {
+ guard let link, let title else { return nil }
+ return WebSearchResult.WebPage(urlString: link, title: title, snippet: snippet ?? "")
+ }
+ }
+
+ func toWebSearchResult() -> WebSearchResult {
+ return WebSearchResult(webPages: organic_results.compactMap { $0.toWebSearchResult() })
+ }
+}
+
+struct SerpAPISearchService: SearchService {
+ let engine: WebSearchProvider.SerpAPIEngine
+ let endpoint: URL = .init(string: "https://serpapi.com/search.json")!
+ let apiKey: String
+
+ init(engine: WebSearchProvider.SerpAPIEngine, apiKey: String) {
+ self.engine = engine
+ self.apiKey = apiKey
+ }
+
+ func search(query: String) async throws -> WebSearchResult {
+ var request = URLRequest(url: endpoint)
+ request.httpMethod = "GET"
+ var urlComponents = URLComponents(url: endpoint, resolvingAgainstBaseURL: true)!
+ urlComponents.queryItems = [
+ URLQueryItem(name: "q", value: query),
+ URLQueryItem(name: "engine", value: engine.rawValue),
+ URLQueryItem(name: "api_key", value: apiKey)
+ ]
+
+ guard let url = urlComponents.url else {
+ throw URLError(.badURL)
+ }
+
+ request = URLRequest(url: url)
+ request.httpMethod = "GET"
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+
+ guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
+ throw URLError(.badServerResponse)
+ }
+
+ // Parse the response into WebSearchResult
+ let decoder = JSONDecoder()
+
+ do {
+ let searchResponse = try decoder.decode(SerpAPIResponse.self, from: data)
+ return searchResponse.toWebSearchResult()
+ } catch {
+ throw error
+ }
+ }
+}
+
diff --git a/Tool/Sources/WebSearchService/WebSearchService.swift b/Tool/Sources/WebSearchService/WebSearchService.swift
new file mode 100644
index 00000000..7eceade4
--- /dev/null
+++ b/Tool/Sources/WebSearchService/WebSearchService.swift
@@ -0,0 +1,75 @@
+import Foundation
+import Preferences
+import Keychain
+
+public enum WebSearchProvider {
+ public enum SerpAPIEngine: String {
+ case google
+ case baidu
+ case bing
+ case duckDuckGo = "duckduckgo"
+ }
+
+ public enum HeadlessBrowserEngine: String {
+ case google
+ case baidu
+ case bing
+ case duckDuckGo = "duckduckgo"
+ }
+
+ case serpAPI(SerpAPIEngine, apiKey: String)
+ case headlessBrowser(HeadlessBrowserEngine)
+ case appleDocumentation
+
+ public static var userPreferred: WebSearchProvider {
+ switch UserDefaults.shared.value(for: \.searchProvider) {
+ case .headlessBrowser:
+ return .headlessBrowser(.init(
+ rawValue: UserDefaults.shared.value(for: \.headlessBrowserEngine).rawValue
+ ) ?? .google)
+ case .serpAPI:
+ let apiKeyName = UserDefaults.shared.value(for: \.serpAPIKeyName)
+ return .serpAPI(.init(
+ rawValue: UserDefaults.shared.value(for: \.serpAPIEngine).rawValue
+ ) ?? .google, apiKey: (try? Keychain.apiKey.get(apiKeyName)) ?? "")
+ }
+ }
+}
+
+public struct WebSearchResult: Equatable {
+ public struct WebPage: Equatable {
+ public var urlString: String
+ public var title: String
+ public var snippet: String
+ }
+
+ public var webPages: [WebPage]
+}
+
+public protocol SearchService {
+ func search(query: String) async throws -> WebSearchResult
+}
+
+public struct WebSearchService {
+ let service: SearchService
+
+ init(service: SearchService) {
+ self.service = service
+ }
+
+ public init(provider: WebSearchProvider) {
+ switch provider {
+ case let .serpAPI(engine, apiKey):
+ service = SerpAPISearchService(engine: engine, apiKey: apiKey)
+ case let .headlessBrowser(engine):
+ service = HeadlessBrowserSearchService(engine: engine)
+ case .appleDocumentation:
+ service = AppleDocumentationSearchService()
+ }
+ }
+
+ public func search(query: String) async throws -> WebSearchResult {
+ return try await service.search(query: query)
+ }
+}
+
diff --git a/Tool/Sources/Workspace/Filespace.swift b/Tool/Sources/Workspace/Filespace.swift
index c3a9f085..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,14 +88,19 @@ 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]
}
// MARK: Life Cycle
public var isExpired: Bool {
- Environment.now().timeIntervalSince(lastUpdateTime) > 60 * 3
+ Environment.now().timeIntervalSince(lastUpdateTime) > 60 * 60
}
public internal(set) var lastUpdateTime: Date = Environment.now()
diff --git a/Tool/Sources/Workspace/Workspace.swift b/Tool/Sources/Workspace/Workspace.swift
index 6579dd72..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? {
@@ -67,6 +67,13 @@ public final class Workspace {
}
}
+ public struct CantFindFileError: Error, LocalizedError {
+ public var fileURL: URL
+ public var errorDescription: String? {
+ "Can't find \(fileURL)."
+ }
+ }
+
private var additionalProperties = WorkspacePropertyValues()
public internal(set) var plugins = [ObjectIdentifier: WorkspacePlugin]()
public let workspaceURL: URL
@@ -107,7 +114,7 @@ public final class Workspace {
let openedFiles = openedFileRecoverableStorage.openedFiles
Task { @WorkspaceActor in
for fileURL in openedFiles {
- _ = createFilespaceIfNeeded(fileURL: fileURL)
+ _ = try? createFilespaceIfNeeded(fileURL: fileURL)
}
}
}
@@ -117,7 +124,25 @@ public final class Workspace {
}
@WorkspaceActor
- public func createFilespaceIfNeeded(fileURL: URL) -> Filespace {
+ public func createFilespaceIfNeeded(
+ fileURL: URL,
+ checkIfFileExists: Bool = true
+ ) throws -> Filespace {
+ let extensionName = fileURL.pathExtension
+ if ["xcworkspace", "xcodeproj"].contains(extensionName) {
+ throw UnsupportedFileError(extensionName: extensionName)
+ }
+ var isDirectory: ObjCBool = false
+ if checkIfFileExists, !FileManager.default.fileExists(
+ atPath: fileURL.path,
+ isDirectory: &isDirectory
+ ) {
+ throw CantFindFileError(fileURL: fileURL)
+ }
+ if isDirectory.boolValue {
+ throw UnsupportedFileError(extensionName: extensionName)
+ }
+
let existedFilespace = filespaces[fileURL]
let filespace = existedFilespace ?? .init(
fileURL: fileURL,
diff --git a/Tool/Sources/Workspace/WorkspacePool.swift b/Tool/Sources/Workspace/WorkspacePool.swift
index 2b9a0737..819f1ecc 100644
--- a/Tool/Sources/Workspace/WorkspacePool.swift
+++ b/Tool/Sources/Workspace/WorkspacePool.swift
@@ -61,6 +61,15 @@ public class WorkspacePool {
}
public func fetchFilespaceIfExisted(fileURL: URL) -> Filespace? {
+ // We prefer to get the filespace from the current active workspace.
+ // Just incase there are multiple workspaces opened with the same file.
+ if let currentWorkspaceURL = XcodeInspector.shared.realtimeActiveWorkspaceURL {
+ if let workspace = workspaces[currentWorkspaceURL],
+ let filespace = workspace.filespaces[fileURL]
+ {
+ return filespace
+ }
+ }
for workspace in workspaces.values {
if let filespace = workspace.filespaces[fileURL] {
return filespace
@@ -68,6 +77,11 @@ public class WorkspacePool {
}
return nil
}
+
+ @WorkspaceActor
+ public func destroy() {
+ workspaces = [:]
+ }
@WorkspaceActor
public func fetchOrCreateWorkspace(workspaceURL: URL) async throws -> Workspace {
@@ -85,20 +99,29 @@ public class WorkspacePool {
}
@WorkspaceActor
- public func fetchOrCreateWorkspaceAndFilespace(fileURL: URL) async throws
+ public func fetchOrCreateWorkspaceAndFilespace(
+ fileURL: URL,
+ checkIfFileExists: Bool = true
+ ) async throws
-> (workspace: Workspace, filespace: Filespace)
{
// If we can get the workspace URL directly.
- if let currentWorkspaceURL = await XcodeInspector.shared.safe.realtimeActiveWorkspaceURL {
+ if let currentWorkspaceURL = XcodeInspector.shared.realtimeActiveWorkspaceURL {
if let existed = workspaces[currentWorkspaceURL] {
// Reuse the existed workspace.
- let filespace = existed.createFilespaceIfNeeded(fileURL: fileURL)
+ let filespace = try existed.createFilespaceIfNeeded(
+ fileURL: fileURL,
+ checkIfFileExists: checkIfFileExists
+ )
return (existed, filespace)
}
let new = createNewWorkspace(workspaceURL: currentWorkspaceURL)
workspaces[currentWorkspaceURL] = new
- let filespace = new.createFilespaceIfNeeded(fileURL: fileURL)
+ let filespace = try new.createFilespaceIfNeeded(
+ fileURL: fileURL,
+ checkIfFileExists: checkIfFileExists
+ )
return (new, filespace)
}
@@ -132,12 +155,15 @@ public class WorkspacePool {
return createNewWorkspace(workspaceURL: workspaceURL)
}()
- let filespace = workspace.createFilespaceIfNeeded(fileURL: fileURL)
+ let filespace = try workspace.createFilespaceIfNeeded(
+ fileURL: fileURL,
+ checkIfFileExists: checkIfFileExists
+ )
workspaces[workspaceURL] = workspace
workspace.refreshUpdateTime()
return (workspace, filespace)
}
-
+
throw Workspace.CantFindWorkspaceError()
}
diff --git a/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift b/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift
index a6f22b2a..99abe305 100644
--- a/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift
+++ b/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift
@@ -33,7 +33,7 @@ public extension Workspace {
) async throws -> [CodeSuggestion] {
refreshUpdateTime()
- let filespace = createFilespaceIfNeeded(fileURL: fileURL)
+ let filespace = try createFilespaceIfNeeded(fileURL: fileURL)
guard !(await filespace.isGitIgnored) else { return [] }
diff --git a/Tool/Sources/XPCShared/XPCService.swift b/Tool/Sources/XPCShared/XPCService.swift
index 6cead390..f4d13db7 100644
--- a/Tool/Sources/XPCShared/XPCService.swift
+++ b/Tool/Sources/XPCShared/XPCService.swift
@@ -45,7 +45,7 @@ class XPCService {
@XPCServiceActor
private func buildConnection() -> InvalidatingConnection {
- logger.info("Rebuilding connection")
+// logger.info("Rebuilding connection")
let connection = switch kind {
case let .machService(name):
NSXPCConnection(machServiceName: name)
@@ -54,14 +54,14 @@ class XPCService {
}
connection.remoteObjectInterface = interface
connection.invalidationHandler = { [weak self] in
- self?.logger.info("XPCService Invalidated")
+// self?.logger.info("XPCService Invalidated")
Task { [weak self] in
self?.markAsInvalidated()
await self?.delegate?.connectionDidInvalidate()
}
}
connection.interruptionHandler = { [weak self] in
- self?.logger.info("XPCService interrupted")
+// self?.logger.info("XPCService interrupted")
Task { [weak self] in
await self?.delegate?.connectionDidInterrupt()
}
diff --git a/Tool/Sources/XPCShared/XPCServiceProtocol.swift b/Tool/Sources/XPCShared/XPCServiceProtocol.swift
index 96120576..ec5aea50 100644
--- a/Tool/Sources/XPCShared/XPCServiceProtocol.swift
+++ b/Tool/Sources/XPCShared/XPCServiceProtocol.swift
@@ -80,7 +80,7 @@ public enum ExtensionServiceRequests {
public struct ServiceInfo: Codable {
public var bundleIdentifier: String
public var name: String
-
+
public init(bundleIdentifier: String, name: String) {
self.bundleIdentifier = bundleIdentifier
self.name = name
@@ -92,6 +92,39 @@ public enum ExtensionServiceRequests {
public init() {}
}
+
+ public struct GetExtensionOpenChatHandlers: ExtensionServiceRequestType {
+ public struct HandlerInfo: Codable {
+ public var bundleIdentifier: String
+ public var id: String
+ public var tabName: String
+ public var isBuiltIn: Bool
+
+ public init(bundleIdentifier: String, id: String, tabName: String, isBuiltIn: Bool) {
+ self.bundleIdentifier = bundleIdentifier
+ self.id = id
+ self.tabName = tabName
+ self.isBuiltIn = isBuiltIn
+ }
+ }
+
+ public typealias ResponseBody = [HandlerInfo]
+ public static let endpoint = "GetExtensionOpenChatHandlers"
+
+ public init() {}
+ }
+
+ public struct GetSuggestionLineAcceptedCode: ExtensionServiceRequestType {
+ public typealias ResponseBody = UpdatedContent?
+
+ public static let endpoint = "GetSuggestionLineAcceptedCode"
+
+ public let editorContent: EditorContent
+
+ public init(editorContent: EditorContent) {
+ self.editorContent = editorContent
+ }
+ }
}
public struct XPCRequestHandlerHitError: Error, LocalizedError {
diff --git a/Tool/Sources/XcodeInspector/AppInstanceInspector.swift b/Tool/Sources/XcodeInspector/AppInstanceInspector.swift
index 1245d98f..cd14dc13 100644
--- a/Tool/Sources/XcodeInspector/AppInstanceInspector.swift
+++ b/Tool/Sources/XcodeInspector/AppInstanceInspector.swift
@@ -1,11 +1,12 @@
import AppKit
import Foundation
-public class AppInstanceInspector: ObservableObject {
- let runningApplication: NSRunningApplication
+open class AppInstanceInspector: @unchecked Sendable {
+ public let runningApplication: NSRunningApplication
public let processIdentifier: pid_t
public let bundleURL: URL?
public let bundleIdentifier: String?
+ public let name: String
public var appElement: AXUIElement {
let app = AXUIElementCreateApplication(runningApplication.processIdentifier)
@@ -38,6 +39,7 @@ public class AppInstanceInspector: ObservableObject {
init(runningApplication: NSRunningApplication) {
self.runningApplication = runningApplication
+ name = runningApplication.localizedName ?? "Unknown"
processIdentifier = runningApplication.processIdentifier
bundleURL = runningApplication.bundleURL
bundleIdentifier = runningApplication.bundleIdentifier
diff --git a/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift
index 76ee96a6..33291631 100644
--- a/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift
+++ b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift
@@ -1,17 +1,20 @@
-import AppKit
+@preconcurrency import AppKit
import AsyncPassthroughSubject
import AXExtension
import AXNotificationStream
import Combine
import Foundation
+import Perception
-public final class XcodeAppInstanceInspector: AppInstanceInspector {
- public struct AXNotification {
+@XcodeInspectorActor
+@Perceptible
+public final class XcodeAppInstanceInspector: AppInstanceInspector, @unchecked Sendable {
+ public struct AXNotification: Sendable {
public var kind: AXNotificationKind
public var element: AXUIElement
}
- public enum AXNotificationKind {
+ public enum AXNotificationKind: Sendable {
case titleChanged
case applicationActivated
case applicationDeactivated
@@ -64,20 +67,70 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector {
}
}
- @Published public fileprivate(set) var focusedWindow: XcodeWindowInspector?
- @Published public fileprivate(set) var documentURL: URL? = nil
- @Published public fileprivate(set) var workspaceURL: URL? = nil
- @Published public fileprivate(set) var projectRootURL: URL? = nil
- @Published public fileprivate(set) var workspaces = [WorkspaceIdentifier: Workspace]()
- @Published public private(set) var completionPanel: AXUIElement?
- public var realtimeWorkspaces: [WorkspaceIdentifier: WorkspaceInfo] {
- updateWorkspaceInfo()
- return workspaces.mapValues(\.info)
+ @MainActor
+ public fileprivate(set) var focusedWindow: XcodeWindowInspector? {
+ didSet {
+ if runningApplication.isActive {
+ NotificationCenter.default.post(name: .focusedWindowDidChange, object: self)
+ }
+ }
+ }
+
+ @MainActor
+ public fileprivate(set) var documentURL: URL? = nil {
+ didSet {
+ if runningApplication.isActive {
+ NotificationCenter.default.post(name: .activeDocumentURLDidChange, object: self)
+ }
+ }
+ }
+
+ @MainActor
+ public fileprivate(set) var workspaceURL: URL? = nil {
+ didSet {
+ if runningApplication.isActive {
+ NotificationCenter.default.post(name: .activeWorkspaceURLDidChange, object: self)
+ }
+ }
+ }
+
+ @MainActor
+ public fileprivate(set) var projectRootURL: URL? = nil {
+ didSet {
+ if runningApplication.isActive {
+ NotificationCenter.default.post(name: .activeProjectRootURLDidChange, object: self)
+ }
+ }
+ }
+
+ @MainActor
+ public fileprivate(set) var workspaces = [WorkspaceIdentifier: Workspace]() {
+ didSet {
+ if runningApplication.isActive {
+ NotificationCenter.default.post(name: .xcodeWorkspacesDidChange, object: self)
+ }
+ }
+ }
+
+ @MainActor
+ public private(set) var completionPanel: AXUIElement? {
+ didSet {
+ if runningApplication.isActive {
+ NotificationCenter.default.post(name: .completionPanelDidChange, object: self)
+ }
+ }
+ }
+
+ private let observer = XcodeInspector.createObserver()
+
+ public nonisolated var realtimeWorkspaces: [WorkspaceIdentifier: WorkspaceInfo] {
+ Self.fetchVisibleWorkspaces(runningApplication).mapValues { $0.info }
}
- public let axNotifications = AsyncPassthroughSubject()
+ public nonisolated let axNotifications = AsyncPassthroughSubject()
- public var realtimeDocumentURL: URL? {
+ public nonisolated
+ var realtimeDocumentURL: URL? {
guard let window = appElement.focusedWindow,
window.identifier == "Xcode.WorkspaceWindow"
else { return nil }
@@ -85,7 +138,8 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector {
return WorkspaceXcodeWindowInspector.extractDocumentURL(windowElement: window)
}
- public var realtimeWorkspaceURL: URL? {
+ public nonisolated
+ var realtimeWorkspaceURL: URL? {
guard let window = appElement.focusedWindow,
window.identifier == "Xcode.WorkspaceWindow"
else { return nil }
@@ -93,7 +147,8 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector {
return WorkspaceXcodeWindowInspector.extractWorkspaceURL(windowElement: window)
}
- public var realtimeProjectURL: URL? {
+ public nonisolated
+ var realtimeProjectURL: URL? {
let workspaceURL = realtimeWorkspaceURL
let documentURL = realtimeDocumentURL
return WorkspaceXcodeWindowInspector.extractProjectURL(
@@ -122,8 +177,7 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector {
return result
}
- private var longRunningTasks = Set>()
- private var focusedWindowObservations = Set()
+ @PerceptionIgnored private var longRunningTasks = Set