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.xcodeproj/xcshareddata/xcschemes/Copilot for Xcode Debug.xcscheme b/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/Copilot for Xcode Debug.xcscheme
new file mode 100644
index 00000000..70ab5d8d
--- /dev/null
+++ b/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/Copilot for Xcode Debug.xcscheme
@@ -0,0 +1,89 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/ExtensionService.xcscheme b/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/ExtensionService.xcscheme
index eef52c11..b72db8dd 100644
--- a/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/ExtensionService.xcscheme
+++ b/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/ExtensionService.xcscheme
@@ -46,9 +46,6 @@
reference = "container:TestPlan.xctestplan"
default = "YES">
-
-
$(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 eb82b462..6cd0910a 100644
--- a/Core/Package.swift
+++ b/Core/Package.swift
@@ -8,13 +8,12 @@ import PackageDescription
let package = Package(
name: "Core",
- platforms: [.macOS(.v12)],
+ platforms: [.macOS(.v13)],
products: [
.library(
name: "Service",
targets: [
"Service",
- "SuggestionInjector",
"FileChangeChecker",
"LaunchAgentManager",
"UpdateChecker",
@@ -38,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",
- from: "1.10.4"
+ exact: "1.16.1"
),
// quick hack to support custom UserDefaults
// https://github.com/sindresorhus/KeyboardShortcuts
@@ -64,8 +65,8 @@ let package = Package(
.product(name: "SuggestionBasic", package: "Tool"),
.product(name: "Logger", package: "Tool"),
.product(name: "Preferences", package: "Tool"),
- ].pro([
- "ProClient",
+ ].proCore([
+ "LicenseManagement",
])
),
.target(
@@ -87,14 +88,18 @@ let package = Package(
.product(name: "UserDefaultsObserver", package: "Tool"),
.product(name: "AppMonitoring", package: "Tool"),
.product(name: "SuggestionBasic", package: "Tool"),
+ .product(name: "PromptToCode", package: "Tool"),
.product(name: "ChatTab", package: "Tool"),
.product(name: "Logger", package: "Tool"),
.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",
])
@@ -104,7 +109,6 @@ let package = Package(
dependencies: [
"Service",
"Client",
- "SuggestionInjector",
.product(name: "XPCShared", package: "Tool"),
.product(name: "SuggestionProvider", package: "Tool"),
.product(name: "SuggestionBasic", package: "Tool"),
@@ -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,25 +147,18 @@ 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",
])
),
- .target(
- name: "SuggestionInjector",
- dependencies: [.product(name: "SuggestionBasic", package: "Tool")]
- ),
- .testTarget(
- name: "SuggestionInjectorTests",
- dependencies: ["SuggestionInjector"]
- ),
// MARK: - Prompt To Code
.target(
name: "PromptToCodeService",
dependencies: [
+ .product(name: "PromptToCode", package: "Tool"),
.product(name: "FocusedCodeFinder", package: "Tool"),
.product(name: "SuggestionBasic", package: "Tool"),
.product(name: "OpenAIService", package: "Tool"),
@@ -177,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"),
@@ -228,6 +224,7 @@ let package = Package(
dependencies: [
"PromptToCodeService",
"ChatGPTChatTab",
+ .product(name: "PromptToCode", package: "Tool"),
.product(name: "Toast", package: "Tool"),
.product(name: "UserDefaultsObserver", package: "Tool"),
.product(name: "SharedUIComponents", package: "Tool"),
@@ -235,6 +232,7 @@ let package = Package(
.product(name: "ChatTab", package: "Tool"),
.product(name: "Logger", package: "Tool"),
.product(name: "CustomAsyncAlgorithms", package: "Tool"),
+ .product(name: "CodeDiff", package: "Tool"),
.product(name: "AsyncAlgorithms", package: "swift-async-algorithms"),
.product(name: "MarkdownUI", package: "swift-markdown-ui"),
.product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
@@ -276,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(
@@ -331,25 +296,27 @@ let package = Package(
],
path: "Sources/ChatContextCollectors/SystemInfoChatContextCollector"
),
-
+
// MARK: Key Binding
.target(
name: "KeyBindingManager",
dependencies: [
+ .product(name: "CommandHandler", package: "Tool"),
.product(name: "Workspace", package: "Tool"),
.product(name: "Preferences", package: "Tool"),
.product(name: "Logger", package: "Tool"),
- .product(name: "CGEventOverride", package: "CGEventOverride"),
.product(name: "AppMonitoring", package: "Tool"),
.product(name: "UserDefaultsObserver", package: "Tool"),
+ .product(name: "CGEventOverride", package: "CGEventOverride"),
+ .product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
]
),
.testTarget(
name: "KeyBindingManagerTests",
dependencies: ["KeyBindingManager"]
),
-
+
// MARK: Theming
.target(
@@ -360,7 +327,6 @@ let package = Package(
.product(name: "Highlightr", package: "Highlightr"),
]
),
-
]
)
@@ -371,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
}
@@ -396,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 16185f94..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)
}
}
@@ -343,15 +342,7 @@ struct Chat {
}
}(),
text: message.summary ?? message.content ?? "",
- references: message.references.map {
- .init(
- title: $0.title,
- subtitle: $0.subTitle,
- uri: $0.uri,
- startLine: $0.startLine,
- kind: $0.kind
- )
- }
+ references: message.references.map(convertReference)
))
for call in message.toolCalls ?? [] {
@@ -513,3 +504,52 @@ private actor TimedDebounceFunction {
}
}
+private func convertReference(
+ _ reference: ChatMessage.Reference
+) -> DisplayedChatMessage.Reference {
+ .init(
+ title: reference.title,
+ subtitle: {
+ switch reference.kind {
+ case let .symbol(_, uri, _, _):
+ return uri
+ case let .webpage(uri):
+ return uri
+ case let .textFile(uri):
+ return uri
+ case let .other(kind):
+ return kind
+ case .text:
+ return reference.content
+ case .error:
+ return reference.content
+ }
+ }(),
+ uri: {
+ switch reference.kind {
+ case let .symbol(_, uri, _, _):
+ return uri
+ case let .webpage(uri):
+ return uri
+ case let .textFile(uri):
+ return uri
+ case .other:
+ return ""
+ case .text:
+ return ""
+ case .error:
+ return ""
+ }
+ }(),
+ startLine: {
+ switch reference.kind {
+ case let .symbol(_, _, startLine, _):
+ return startLine
+ default:
+ return nil
+ }
+ }(),
+ kind: reference.kind
+ )
+}
+
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/ChatPanel.swift b/Core/Sources/ChatGPTChatTab/ChatPanel.swift
index 139ed7ab..9210a05d 100644
--- a/Core/Sources/ChatGPTChatTab/ChatPanel.swift
+++ b/Core/Sources/ChatGPTChatTab/ChatPanel.swift
@@ -504,7 +504,7 @@ struct ChatPanel_Preview: PreviewProvider {
subtitle: "Hi Hi Hi Hi",
uri: "https://google.com",
startLine: nil,
- kind: .class
+ kind: .symbol(.class, uri: "https://google.com", startLine: nil, endLine: nil)
),
]
),
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 2605d6d5..bcd9a455 100644
--- a/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift
+++ b/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift
@@ -139,68 +139,82 @@ struct ReferenceIcon: View {
RoundedRectangle(cornerRadius: 4)
.fill({
switch kind {
- 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 .symbol(let 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: 22, height: 22)
.overlay(alignment: .center) {
Group {
switch kind {
- 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 .symbol(let 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("Tx")
case .webpage:
Text("Wb")
case .other:
Text("Ot")
+ case .textFile:
+ Text("Tx")
+ case .error:
+ Text("Er")
}
}
.font(.system(size: 12).monospaced())
@@ -225,7 +239,7 @@ struct ReferenceIcon: View {
subtitle: "/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift:100",
uri: "https://google.com",
startLine: nil,
- kind: .class
+ kind: .symbol(.class, uri: "https://google.com", startLine: nil, endLine: nil)
), count: 20),
chat: .init(initialState: .init(), reducer: { Chat(service: .init()) })
)
@@ -240,43 +254,42 @@ struct ReferenceIcon: View {
subtitle: "/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift:100",
uri: "https://google.com",
startLine: nil,
- kind: .class
+ kind: .symbol(.class, uri: "https://google.com", startLine: nil, endLine: nil)
),
.init(
title: "BotMessage.swift:100-102",
subtitle: "/Core/Sources/ChatGPTChatTab/Views",
uri: "https://google.com",
startLine: nil,
- kind: .struct
+ kind: .symbol(.struct, uri: "https://google.com", startLine: nil, endLine: nil)
),
.init(
title: "ReferenceList",
subtitle: "/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift:100",
uri: "https://google.com",
startLine: nil,
- kind: .function
+ kind: .symbol(.function, uri: "https://google.com", startLine: nil, endLine: nil)
),
.init(
title: "ReferenceList",
subtitle: "/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift:100",
uri: "https://google.com",
startLine: nil,
- kind: .case
+ kind: .symbol(.case, uri: "https://google.com", startLine: nil, endLine: nil)
),
.init(
title: "ReferenceList",
subtitle: "/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift:100",
uri: "https://google.com",
startLine: nil,
- kind: .extension
+ kind: .symbol(.extension, uri: "https://google.com", startLine: nil, endLine: nil)
),
.init(
title: "ReferenceList",
subtitle: "/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift:100",
uri: "https://google.com",
startLine: nil,
- kind: .webpage
+ kind: .webpage(uri: "https://google.com")
),
], chat: .init(initialState: .init(), reducer: { Chat(service: .init()) }))
}
-
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 15da1780..2811e4ad 100644
--- a/Core/Sources/ChatGPTChatTab/Views/ThemedMarkdownText.swift
+++ b/Core/Sources/ChatGPTChatTab/Views/ThemedMarkdownText.swift
@@ -15,9 +15,9 @@ struct ThemedMarkdownText: View {
let content: MarkdownContent
init(_ text: String) {
- self.content = .init(text)
+ content = .init(text)
}
-
+
init(_ content: MarkdownContent) {
self.content = content
}
@@ -71,6 +71,11 @@ extension MarkdownUI.Theme {
}
.codeBlock { configuration in
let wrapCode = UserDefaults.shared.value(for: \.wrapCodeInChatCodeBlock)
+ || [
+ "plaintext", "text", "markdown", "sh", "console", "bash", "shell", "latex",
+ "tex"
+ ]
+ .contains(configuration.language)
if wrapCode {
AsyncCodeBlockView(
diff --git a/Core/Sources/ChatPlugin/AITerminalChatPlugin.swift b/Core/Sources/ChatPlugin/AITerminalChatPlugin.swift
deleted file mode 100644
index 6e95f29d..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 ChatGPTServiceType
- var terminal: TerminalType = Terminal()
- var isCancelled = false
- weak var delegate: ChatPluginDelegate?
- var isStarted = false
- var command: String?
-
- public init(inside chatGPTService: any ChatGPTServiceType, 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 99cf6028..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 ChatGPTServiceType
- var isCancelled = false
- weak var delegate: ChatPluginDelegate?
-
- public init(inside chatGPTService: any ChatGPTServiceType, 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 c6a9bddf..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 ChatGPTServiceType
- var terminal: TerminalType = Terminal()
- var isCancelled = false
- weak var delegate: ChatPluginDelegate?
-
- public init(inside chatGPTService: any ChatGPTServiceType, 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 5616f072..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 ChatGPTServiceType
- var terminal: TerminalType = Terminal()
- var isCancelled = false
- weak var delegate: ChatPluginDelegate?
-
- public init(inside chatGPTService: any ChatGPTServiceType, 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 99a7c629..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 ChatGPTServiceType
- let plugins: [String: ChatPlugin.Type]
- var runningPlugin: ChatPlugin?
+ let chatGPTService: any LegacyChatGPTServiceType
+ let plugins: [String: LegacyChatPlugin.Type]
+ var runningPlugin: LegacyChatPlugin?
weak var chatService: ChatService?
-
- init(chatGPTService: any ChatGPTServiceType, 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 ChatGPTServiceType, 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 4bb74639..e1b0eb54 100644
--- a/Core/Sources/ChatService/ChatService.swift
+++ b/Core/Sources/ChatService/ChatService.swift
@@ -1,16 +1,17 @@
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 ChatGPTServiceType
+ public let chatGPTService: any LegacyChatGPTServiceType
public var allPluginCommands: [String] { allPlugins.map { $0.command } }
@Published public internal(set) var chatHistory: [ChatMessage] = []
@Published public internal(set) var isReceivingMessage = false
@@ -22,7 +23,7 @@ public final class ChatService: ObservableObject {
let pluginController: ChatPluginController
var cancellable = Set()
- init(
+ init(
memory: ContextAwareAutoManagedChatGPTMemory,
configuration: OverridingChatGPTConfiguration,
chatGPTService: T
@@ -53,7 +54,7 @@ public final class ChatService: ObservableObject {
self.init(
memory: memory,
configuration: configuration,
- chatGPTService: ChatGPTService(
+ chatGPTService: LegacyChatGPTService(
memory: memory,
configuration: extraConfiguration,
functionProvider: memory.functionProvider
@@ -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/ChatService/ContextAwareAutoManagedChatGPTMemory.swift b/Core/Sources/ChatService/ContextAwareAutoManagedChatGPTMemory.swift
index ac44d87c..32d65694 100644
--- a/Core/Sources/ChatService/ContextAwareAutoManagedChatGPTMemory.swift
+++ b/Core/Sources/ChatService/ContextAwareAutoManagedChatGPTMemory.swift
@@ -22,7 +22,8 @@ public final class ContextAwareAutoManagedChatGPTMemory: ChatGPTMemory {
memory = AutoManagedChatGPTMemory(
systemPrompt: "",
configuration: configuration,
- functionProvider: functionProvider
+ functionProvider: functionProvider,
+ maxNumberOfMessages: UserDefaults.shared.value(for: \.chatGPTMaxMessageCount)
)
contextController = DynamicContextController(
memory: memory,
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 7450105e..f0c673e5 100644
--- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift
+++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift
@@ -29,6 +29,13 @@ struct ChatModelEdit {
var apiKeySelection: APIKeySelection.State = .init()
var baseURLSelection: BaseURLSelection.State = .init()
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 {
@@ -41,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 {
@@ -85,21 +126,33 @@ struct ChatModelEdit {
let model = ChatModel(state: state)
return .run { send in
do {
- let service = ChatGPTService(
- 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))
@@ -148,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
@@ -192,14 +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)
)
)
}
@@ -219,7 +327,15 @@ extension ChatModel {
apiKeyManagement: .init(availableAPIKeyNames: [info.apiKeyName])
),
baseURLSelection: .init(baseURL: info.baseURL, isFullURL: info.isFullURL),
- enforceMessageOrder: info.openAICompatibleInfo.enforceMessageOrder
+ enforceMessageOrder: info.openAICompatibleInfo.enforceMessageOrder,
+ openAIOrganizationID: info.openAIInfo.organizationID,
+ 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 1eee9725..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 {
@@ -244,6 +351,18 @@ struct ChatModelEditView: View {
MaxTokensTextField(store: store)
SupportsFunctionCallingToggle(store: store)
+ TextField(text: $store.openAIOrganizationID, prompt: Text("Optional")) {
+ Text("Organization ID")
+ }
+
+ 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(
" To get an API key, please visit [https://platform.openai.com/api-keys](https://platform.openai.com/api-keys)"
@@ -271,6 +390,10 @@ struct ChatModelEditView: View {
MaxTokensTextField(store: store)
SupportsFunctionCallingToggle(store: store)
+
+ Toggle(isOn: $store.supportsImages) {
+ Text("Supports Images")
+ }
}
}
}
@@ -308,10 +431,22 @@ struct ChatModelEditView: View {
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.requiresBeginWithUserMessage) {
+ Text("Requires the first message to be from the user")
+ }
+
+ Toggle(isOn: $store.supportsImages) {
+ Text("Supports Images")
+ }
}
}
}
@@ -350,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)
@@ -370,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)."
@@ -387,9 +533,9 @@ struct ChatModelEditView: View {
BaseURLTextField(store: store, prompt: Text("https://api.anthropic.com")) {
Text("/v1/messages")
}
-
+
ApiKeyNamePicker(store: store)
-
+
TextField("Model Name", text: $store.modelName)
.overlay(alignment: .trailing) {
Picker(
@@ -411,9 +557,13 @@ struct ChatModelEditView: View {
)
.frame(width: 20)
}
-
+
MaxTokensTextField(store: store)
+ 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://anthropic.com](https://anthropic.com)."
@@ -423,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 13f37404..033b9850 100644
--- a/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift
+++ b/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift
@@ -149,7 +149,7 @@ struct CustomCommandView: View {
case .customChat:
Text("Custom Chat")
case .promptToCode:
- Text("Prompt to Code")
+ Text("Modification")
case .singleRoundDialog:
Text("Single Round Dialog")
}
@@ -198,22 +198,22 @@ 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("Prompt to Code")) {
+ SubSection(title: Text("Modification")) {
Text(
"This command opens the prompt-to-code panel and executes the provided requirements on the selected code. You can provide additional context through the \"Extra Context\" as well."
)
}
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 c2a0f5c0..e2304f8b 100644
--- a/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift
+++ b/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift
@@ -37,7 +37,7 @@ struct EditCustomCommandView: View {
case .sendMessage:
return "Send Message"
case .promptToCode:
- return "Prompt to Code"
+ return "Modification"
case .customChat:
return "Custom Chat"
case .singleRoundDialog:
@@ -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/SuggestionSettingsCheatsheetSectionView.swift b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsCheatsheetSectionView.swift
deleted file mode 100644
index d0da53a1..00000000
--- a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsCheatsheetSectionView.swift
+++ /dev/null
@@ -1,71 +0,0 @@
-import Client
-import Preferences
-import SharedUIComponents
-import SwiftUI
-import XPCShared
-
-#if canImport(ProHostApp)
-import ProHostApp
-#endif
-
-struct SuggestionSettingsCheatsheetSectionView: View {
- final class Settings: ObservableObject {
- @AppStorage(\.isSuggestionSenseEnabled)
- var isSuggestionSenseEnabled
- @AppStorage(\.isSuggestionTypeInTheMiddleEnabled)
- var isSuggestionTypeInTheMiddleEnabled
- }
-
- @StateObject var settings = Settings()
-
- var body: some View {
- #if canImport(ProHostApp)
- SubSection(
- title: Text("Suggestion Sense (Experimental)"),
- description: Text("""
- This cheatsheet will try to improve the suggestion by inserting relevant symbol \
- interfaces in the editing scope to the prompt.
-
- Some suggestion services may have their own RAG system with a higher priority.
- """)
- ) {
- Form {
- WithFeatureEnabled(\.suggestionSense) {
- Toggle(isOn: $settings.isSuggestionSenseEnabled) {
- Text("Enable suggestion sense")
- }
- }
- }
- }
-
- SubSection(
- title: Text("Type-in-the-Middle Hack"),
- description: Text("""
- Suggestion service don't always handle the case where the text cursor is in the middle \
- of a line. This cheatsheet will try to trick the suggestion service to also generate \
- suggestions in these cases.
-
- It can be useful in the following cases:
- - Fixing a typo in the middle of a line.
- - Getting suggestions from a line with Xcode placeholders.
- - and more...
- """)
- ) {
- Form {
- Toggle(isOn: $settings.isSuggestionTypeInTheMiddleEnabled) {
- Text("Enable type-in-the-middle hack")
- }
- }
- }
-
- #else
- Text("Not Available")
- #endif
- }
-}
-
-#Preview {
- SuggestionSettingsCheatsheetSectionView()
- .padding()
-}
-
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/FeatureSettings/Suggestion/SuggestionSettingsView.swift b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsView.swift
index 7cc461bb..632769a4 100644
--- a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsView.swift
+++ b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsView.swift
@@ -4,14 +4,14 @@ import SharedUIComponents
import SwiftUI
import XPCShared
-#if canImport(ProHostApp)
-import ProHostApp
-#endif
-
struct SuggestionSettingsView: View {
- enum Tab {
+ var tabContainer: ExternalTabContainer {
+ ExternalTabContainer.tabContainer(for: "SuggestionSettings")
+ }
+
+ enum Tab: Hashable {
case general
- case suggestionCheatsheet
+ case other(String)
}
@State var tabSelection: Tab = .general
@@ -20,7 +20,9 @@ struct SuggestionSettingsView: View {
VStack(spacing: 0) {
Picker("", selection: $tabSelection) {
Text("General").tag(Tab.general)
- Text("Cheatsheet").tag(Tab.suggestionCheatsheet)
+ ForEach(tabContainer.tabs, id: \.id) { tab in
+ Text(tab.title).tag(Tab.other(tab.id))
+ }
}
.pickerStyle(.segmented)
.padding(8)
@@ -33,8 +35,8 @@ struct SuggestionSettingsView: View {
switch tabSelection {
case .general:
SuggestionSettingsGeneralSectionView()
- case .suggestionCheatsheet:
- SuggestionSettingsCheatsheetSectionView()
+ case let .other(id):
+ tabContainer.tabView(for: id)
}
}.padding()
}
diff --git a/Core/Sources/HostApp/FeatureSettingsView.swift b/Core/Sources/HostApp/FeatureSettingsView.swift
index ed0f186b..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 {
@@ -26,8 +31,8 @@ struct FeatureSettingsView: View {
}
.sidebarItem(
tag: 2,
- title: "Prompt to Code",
- subtitle: "Write code with natural language",
+ title: "Modification",
+ subtitle: "Write or modify code with natural language",
image: "paintbrush"
)
@@ -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/General.swift b/Core/Sources/HostApp/General.swift
index 0dca4505..96ade16c 100644
--- a/Core/Sources/HostApp/General.swift
+++ b/Core/Sources/HostApp/General.swift
@@ -12,49 +12,196 @@ struct General {
var xpcServiceVersion: String?
var isAccessibilityPermissionGranted: Bool?
var isReloading = false
+ @Presents var alert: AlertState?
}
- enum Action: Equatable {
+ enum Action {
case appear
case setupLaunchAgentIfNeeded
+ case setupLaunchAgentClicked
+ case removeLaunchAgentClicked
+ case reloadLaunchAgentClicked
case openExtensionManager
case reloadStatus
case finishReloading(xpcServiceVersion: String, permissionGranted: Bool)
case failedReloading
+ case alert(PresentationAction)
+
+ case setupLaunchAgent
+ case finishSetupLaunchAgent
+ case finishRemoveLaunchAgent
+ case finishReloadLaunchAgent
+
+ @CasePathable
+ enum Alert: Equatable {
+ case moveToApplications
+ case moveTo(URL)
+ case install
+ }
}
@Dependency(\.toast) var toast
-
+
struct ReloadStatusCancellableId: Hashable {}
+ static var didWarnInstallationPosition: Bool {
+ get { UserDefaults.standard.bool(forKey: "didWarnInstallationPosition") }
+ set { UserDefaults.standard.set(newValue, forKey: "didWarnInstallationPosition") }
+ }
+
+ static var bundleIsInApplicationsFolder: Bool {
+ Bundle.main.bundleURL.path.hasPrefix("/Applications")
+ }
+
var body: some ReducerOf {
Reduce { state, action in
switch action {
case .appear:
- return .run { send in
- if UserDefaults.shared.value(for: \.doNotInstallLaunchAgentAutomatically) {
- return
+ if Self.bundleIsInApplicationsFolder {
+ return .run { send in
+ await send(.setupLaunchAgentIfNeeded)
}
- await send(.setupLaunchAgentIfNeeded)
}
+ if !Self.didWarnInstallationPosition {
+ Self.didWarnInstallationPosition = true
+ state.alert = .init {
+ TextState("Move to Applications Folder?")
+ } actions: {
+ ButtonState(action: .moveToApplications) {
+ TextState("Move")
+ }
+ ButtonState(role: .cancel) {
+ TextState("Not Now")
+ }
+ } message: {
+ TextState(
+ "To ensure the best experience, please move the app to the Applications folder. If the app is not inside the Applications folder, please set up the launch agent manually by clicking the button."
+ )
+ }
+ }
+
+ return .none
+
case .setupLaunchAgentIfNeeded:
return .run { send in
#if DEBUG
// do not auto install on debug build
#else
- Task {
- do {
- try await LaunchAgentManager()
- .setupLaunchAgentForTheFirstTimeIfNeeded()
- } catch {
- toast(error.localizedDescription, .error)
- }
+ do {
+ try await LaunchAgentManager()
+ .setupLaunchAgentForTheFirstTimeIfNeeded()
+ } catch {
+ toast(error.localizedDescription, .error)
}
#endif
await send(.reloadStatus)
}
+ case .setupLaunchAgentClicked:
+ if Self.bundleIsInApplicationsFolder {
+ return .run { send in
+ await send(.setupLaunchAgent)
+ }
+ }
+
+ state.alert = .init {
+ TextState("Setup Launch Agent")
+ } actions: {
+ ButtonState(action: .install) {
+ TextState("Setup")
+ }
+
+ ButtonState(action: .moveToApplications) {
+ TextState("Move to Applications Folder")
+ }
+
+ ButtonState(role: .cancel) {
+ TextState("Cancel")
+ }
+ } message: {
+ TextState(
+ "It's recommended to move the app into the Applications folder. But you can still keep it in the current folder and install the launch agent to ~/Library/LaunchAgents."
+ )
+ }
+
+ return .none
+
+ case .removeLaunchAgentClicked:
+ return .run { send in
+ do {
+ try await LaunchAgentManager().removeLaunchAgent()
+ await send(.finishRemoveLaunchAgent)
+ } catch {
+ toast(error.localizedDescription, .error)
+ }
+ await send(.reloadStatus)
+ }
+
+ case .reloadLaunchAgentClicked:
+ return .run { send in
+ do {
+ try await LaunchAgentManager().reloadLaunchAgent()
+ await send(.finishReloadLaunchAgent)
+ } catch {
+ toast(error.localizedDescription, .error)
+ }
+ await send(.reloadStatus)
+ }
+
+ case .setupLaunchAgent:
+ return .run { send in
+ do {
+ try await LaunchAgentManager().setupLaunchAgent()
+ await send(.finishSetupLaunchAgent)
+ } catch {
+ toast(error.localizedDescription, .error)
+ }
+ await send(.reloadStatus)
+ }
+
+ case .finishSetupLaunchAgent:
+ state.alert = .init {
+ TextState("Launch Agent Installed")
+ } actions: {
+ ButtonState {
+ TextState("OK")
+ }
+ } message: {
+ TextState(
+ "The launch agent has been installed. Please restart the app."
+ )
+ }
+ return .none
+
+ case .finishRemoveLaunchAgent:
+ state.alert = .init {
+ TextState("Launch Agent Removed")
+ } actions: {
+ ButtonState {
+ TextState("OK")
+ }
+ } message: {
+ TextState(
+ "The launch agent has been removed."
+ )
+ }
+ return .none
+
+ case .finishReloadLaunchAgent:
+ state.alert = .init {
+ TextState("Launch Agent Reloaded")
+ } actions: {
+ ButtonState {
+ TextState("OK")
+ }
+ } message: {
+ TextState(
+ "The launch agent has been reloaded."
+ )
+ }
+ return .none
+
case .openExtensionManager:
return .run { send in
let service = try getService()
@@ -107,6 +254,38 @@ struct General {
case .failedReloading:
state.isReloading = false
return .none
+
+ case let .alert(.presented(action)):
+ switch action {
+ case .moveToApplications:
+ return .run { send in
+ let appURL = URL(fileURLWithPath: "/Applications")
+ await send(.alert(.presented(.moveTo(appURL))))
+ }
+
+ case let .moveTo(url):
+ return .run { _ in
+ do {
+ try FileManager.default.moveItem(
+ at: Bundle.main.bundleURL,
+ to: url.appendingPathComponent(
+ Bundle.main.bundleURL.lastPathComponent
+ )
+ )
+ await NSApplication.shared.terminate(nil)
+ } catch {
+ toast(error.localizedDescription, .error)
+ }
+ }
+ case .install:
+ return .run { send in
+ await send(.setupLaunchAgent)
+ }
+ }
+
+ case .alert(.dismiss):
+ state.alert = nil
+ return .none
}
}
}
diff --git a/Core/Sources/HostApp/GeneralView.swift b/Core/Sources/HostApp/GeneralView.swift
index ba57a242..b69c0127 100644
--- a/Core/Sources/HostApp/GeneralView.swift
+++ b/Core/Sources/HostApp/GeneralView.swift
@@ -16,7 +16,7 @@ struct GeneralView: View {
SettingsDivider()
ExtensionServiceView(store: store)
SettingsDivider()
- LaunchAgentView()
+ LaunchAgentView(store: store)
SettingsDivider()
GeneralSettingsView()
}
@@ -30,64 +30,68 @@ struct GeneralView: View {
struct AppInfoView: View {
@State var appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
@Environment(\.updateChecker) var updateChecker
- let store: StoreOf
+ @Perception.Bindable var store: StoreOf
var body: some View {
- VStack(alignment: .leading) {
- HStack(alignment: .top) {
- Text(
- Bundle.main
- .object(forInfoDictionaryKey: "HOST_APP_NAME") as? String
- ?? "Copilot for Xcode"
- )
- .font(.title)
- Text(appVersion ?? "")
- .font(.footnote)
- .foregroundColor(.secondary)
-
- Spacer()
+ WithPerceptionTracking {
+ VStack(alignment: .leading) {
+ HStack(alignment: .top) {
+ Text(
+ Bundle.main
+ .object(forInfoDictionaryKey: "HOST_APP_NAME") as? String
+ ?? "Copilot for Xcode"
+ )
+ .font(.title)
+ Text(appVersion ?? "")
+ .font(.footnote)
+ .foregroundColor(.secondary)
- Button(action: {
- store.send(.openExtensionManager)
- }) {
- HStack(spacing: 2) {
- Image(systemName: "puzzlepiece.extension.fill")
- Text("Extensions")
+ Spacer()
+
+ Button(action: {
+ store.send(.openExtensionManager)
+ }) {
+ HStack(spacing: 2) {
+ Image(systemName: "puzzlepiece.extension.fill")
+ Text("Extensions")
+ }
}
- }
- Button(action: {
- updateChecker.checkForUpdates()
- }) {
- HStack(spacing: 2) {
- Image(systemName: "arrow.up.right.circle.fill")
- Text("Check for Updates")
+ Button(action: {
+ updateChecker.checkForUpdates()
+ }) {
+ HStack(spacing: 2) {
+ Image(systemName: "arrow.up.right.circle.fill")
+ Text("Check for Updates")
+ }
}
}
- }
- HStack(spacing: 16) {
- Link(
- destination: URL(string: "https://github.com/intitni/CopilotForXcode")!
- ) {
- HStack(spacing: 2) {
- Image(systemName: "link")
- Text("GitHub")
+ HStack(spacing: 16) {
+ Link(
+ destination: URL(string: "https://github.com/intitni/CopilotForXcode")!
+ ) {
+ HStack(spacing: 2) {
+ Image(systemName: "link")
+ Text("GitHub")
+ }
}
- }
- .focusable(false)
- .foregroundColor(.accentColor)
+ .focusable(false)
+ .foregroundColor(.accentColor)
- Link(destination: URL(string: "https://www.buymeacoffee.com/intitni")!) {
- HStack(spacing: 2) {
- Image(systemName: "cup.and.saucer.fill")
- Text("Buy Me A Coffee")
+ Link(destination: URL(string: "https://www.buymeacoffee.com/intitni")!) {
+ HStack(spacing: 2) {
+ Image(systemName: "cup.and.saucer.fill")
+ Text("Buy Me A Coffee")
+ }
}
+ .foregroundColor(.accentColor)
+ .focusable(false)
}
- .foregroundColor(.accentColor)
- .focusable(false)
}
- }.padding()
+ .padding()
+ .alert($store.scope(state: \.alert, action: \.alert))
+ }
}
}
@@ -149,75 +153,34 @@ struct ExtensionServiceView: View {
}
struct LaunchAgentView: View {
+ @Perception.Bindable var store: StoreOf
@Environment(\.toast) var toast
- @State var isDidRemoveLaunchAgentAlertPresented = false
- @State var isDidSetupLaunchAgentAlertPresented = false
- @State var isDidRestartLaunchAgentAlertPresented = false
var body: some View {
- VStack(alignment: .leading) {
- HStack {
- Button(action: {
- Task {
- do {
- try await LaunchAgentManager().setupLaunchAgent()
- isDidSetupLaunchAgentAlertPresented = true
- } catch {
- toast(error.localizedDescription, .error)
- }
+ WithPerceptionTracking {
+ VStack(alignment: .leading) {
+ HStack {
+ Button(action: {
+ store.send(.setupLaunchAgentClicked)
+ }) {
+ Text("Setup Launch Agent")
}
- }) {
- Text("Set Up Launch Agent")
- }
- .alert(isPresented: $isDidSetupLaunchAgentAlertPresented) {
- .init(
- title: Text("Finished Launch Agent Setup"),
- message: Text(
- "Please refresh the Copilot status. (The first refresh may fail)"
- ),
- dismissButton: .default(Text("OK"))
- )
- }
- Button(action: {
- Task {
- do {
- try await LaunchAgentManager().removeLaunchAgent()
- isDidRemoveLaunchAgentAlertPresented = true
- } catch {
- toast(error.localizedDescription, .error)
- }
+ Button(action: {
+ store.send(.removeLaunchAgentClicked)
+ }) {
+ Text("Remove Launch Agent")
}
- }) {
- Text("Remove Launch Agent")
- }
- .alert(isPresented: $isDidRemoveLaunchAgentAlertPresented) {
- .init(
- title: Text("Launch Agent Removed"),
- dismissButton: .default(Text("OK"))
- )
- }
- Button(action: {
- Task {
- do {
- try await LaunchAgentManager().reloadLaunchAgent()
- isDidRestartLaunchAgentAlertPresented = true
- } catch {
- toast(error.localizedDescription, .error)
- }
+ Button(action: {
+ store.send(.reloadLaunchAgentClicked)
+ }) {
+ Text("Reload Launch Agent")
}
- }) {
- Text("Reload Launch Agent")
- }.alert(isPresented: $isDidRestartLaunchAgentAlertPresented) {
- .init(
- title: Text("Launch Agent Reloaded"),
- dismissButton: .default(Text("OK"))
- )
}
}
+ .padding()
}
- .padding()
}
}
@@ -317,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 69ec3120..f2b90303 100644
--- a/Core/Sources/HostApp/HostApp.swift
+++ b/Core/Sources/HostApp/HostApp.swift
@@ -4,7 +4,7 @@ import Foundation
import KeyboardShortcuts
#if canImport(LicenseManagement)
-import LicenseManagement
+import ProHostApp
#endif
extension KeyboardShortcuts.Name {
@@ -18,14 +18,15 @@ struct HostApp {
var general = General.State()
var chatModelManagement = ChatModelManagement.State()
var embeddingModelManagement = EmbeddingModelManagement.State()
+ var webSearchSettings = WebSearchSettings.State()
}
- enum Action: Equatable {
+ enum Action {
case appear
- case informExtensionServiceAboutLicenseKeyChange
case general(General.Action)
case chatModelManagement(ChatModelManagement.Action)
case embeddingModelManagement(EmbeddingModelManagement.Action)
+ case webSearchSettings(WebSearchSettings.Action)
}
@Dependency(\.toast) var toast
@@ -35,37 +36,29 @@ 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 {
case .appear:
- return .none
-
- case .informExtensionServiceAboutLicenseKeyChange:
- #if canImport(LicenseManagement)
- return .run { _ in
- let service = try getService()
- do {
- try await service
- .postNotification(name: Notification.Name.licenseKeyChanged.rawValue)
- } catch {
- toast(error.localizedDescription, .error)
- }
- }
- #else
- return .none
+ #if canImport(ProHostApp)
+ ProHostApp.start()
#endif
+ return .none
case .general:
return .none
@@ -75,6 +68,9 @@ struct HostApp {
case .embeddingModelManagement:
return .none
+
+ case .webSearchSettings:
+ return .none
}
}
}
diff --git a/Core/Sources/HostApp/LaunchAgentManager.swift b/Core/Sources/HostApp/LaunchAgentManager.swift
index ee031cb5..44937bb1 100644
--- a/Core/Sources/HostApp/LaunchAgentManager.swift
+++ b/Core/Sources/HostApp/LaunchAgentManager.swift
@@ -7,11 +7,10 @@ extension LaunchAgentManager {
serviceIdentifier: Bundle.main
.object(forInfoDictionaryKey: "BUNDLE_IDENTIFIER_BASE") as! String +
".CommunicationBridge",
- executablePath: Bundle.main.bundleURL
+ executableURL: Bundle.main.bundleURL
.appendingPathComponent("Contents")
.appendingPathComponent("Applications")
- .appendingPathComponent("CommunicationBridge")
- .path,
+ .appendingPathComponent("CommunicationBridge"),
bundleIdentifier: Bundle.main
.object(forInfoDictionaryKey: "BUNDLE_IDENTIFIER_BASE") as! String
)
diff --git a/Core/Sources/HostApp/ServiceView.swift b/Core/Sources/HostApp/ServiceView.swift
index 07f8af77..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,36 +26,37 @@ struct ServiceView: View {
subtitle: "Suggestion",
image: "globe"
)
-
+
ChatModelManagementView(store: store.scope(
state: \.chatModelManagement,
action: \.chatModelManagement
)).sidebarItem(
tag: 2,
title: "Chat Models",
- subtitle: "Chat, Prompt to Code",
+ subtitle: "Chat, Modification",
image: "globe"
)
-
+
EmbeddingModelManagementView(store: store.scope(
state: \.embeddingModelManagement,
action: \.embeddingModelManagement
)).sidebarItem(
tag: 3,
title: "Embedding Models",
- subtitle: "Chat, Prompt to Code",
+ 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/HostApp/TabContainer.swift b/Core/Sources/HostApp/TabContainer.swift
index 30ae0187..8616b5af 100644
--- a/Core/Sources/HostApp/TabContainer.swift
+++ b/Core/Sources/HostApp/TabContainer.swift
@@ -2,14 +2,11 @@ import ComposableArchitecture
import Dependencies
import Foundation
import LaunchAgentManager
+import SharedUIComponents
import SwiftUI
import Toast
import UpdateChecker
-#if canImport(ProHostApp)
-import ProHostApp
-#endif
-
@MainActor
let hostAppStore: StoreOf = .init(initialState: .init(), reducer: { HostApp() })
@@ -19,6 +16,10 @@ public struct TabContainer: View {
@State private var tabBarItems = [TabBarItem]()
@State var tag: Int = 0
+ var externalTabContainer: ExternalTabContainer {
+ ExternalTabContainer.tabContainer(for: "TabContainer")
+ }
+
public init() {
toastController = ToastControllerDependencyKey.liveValue
store = hostAppStore
@@ -59,15 +60,16 @@ public struct TabContainer: View {
title: "Custom Command",
image: "command.square"
)
- #if canImport(ProHostApp)
- PlusView(onLicenseKeyChanged: {
- store.send(.informExtensionServiceAboutLicenseKeyChange)
- }).tabBarItem(
- tag: 5,
- title: "Plus",
- image: "plus.diamond"
- )
- #endif
+
+ ForEach(0.. Void,
- dismissSuggestion: @escaping () -> Void
- ) {
- tabToAcceptSuggestion = .init(
- workspacePool: workspacePool,
- acceptSuggestion: acceptSuggestion,
- dismissSuggestion: dismissSuggestion
- )
+ public init() {
+ tabToAcceptSuggestion = .init()
}
public func start() {
diff --git a/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift b/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift
index f1e154e5..9c81038f 100644
--- a/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift
+++ b/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift
@@ -1,6 +1,8 @@
import ActiveApplicationMonitor
import AppKit
import CGEventOverride
+import CommandHandler
+import Dependencies
import Foundation
import Logger
import Preferences
@@ -14,9 +16,9 @@ final class TabToAcceptSuggestion {
Logger.service.debug("TabToAcceptSuggestion: \(message)")
}
- let workspacePool: WorkspacePool
- let acceptSuggestion: () -> Void
- let dismissSuggestion: () -> Void
+ @Dependency(\.workspacePool) var workspacePool
+ @Dependency(\.commandHandler) var commandHandler
+
private var CGEventObservationTask: Task?
private var isObserving: Bool { CGEventObservationTask != nil }
private let userDefaultsObserver = UserDefaultsObserver(
@@ -43,15 +45,8 @@ final class TabToAcceptSuggestion {
stopObservation()
}
- init(
- workspacePool: WorkspacePool,
- acceptSuggestion: @escaping () -> Void,
- dismissSuggestion: @escaping () -> Void
- ) {
+ init() {
_ = ThreadSafeAccessToXcodeInspector.shared
- self.workspacePool = workspacePool
- self.acceptSuggestion = acceptSuggestion
- self.dismissSuggestion = dismissSuggestion
hook.add(
.init(
@@ -110,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
+ }
+
+ let editorContent = editor.getContent()
+
+ let shouldAcceptSuggestion = Self.checkIfAcceptSuggestion(
+ lines: editorContent.lines,
+ cursorPosition: editorContent.cursorPosition,
+ codeMetadata: filespace.codeMetadata,
+ presentingSuggestionText: presentingSuggestion.text
+ )
- if shouldAcceptSuggestion {
- acceptSuggestion()
- return .discarded
+ 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 }
-
- 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 {
@@ -253,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/LaunchAgentManager/LaunchAgentManager.swift b/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift
index e0c8f814..3caf179b 100644
--- a/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift
+++ b/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift
@@ -4,7 +4,7 @@ import ServiceManagement
public struct LaunchAgentManager {
let lastLaunchAgentVersionKey = "LastLaunchAgentVersion"
let serviceIdentifier: String
- let executablePath: String
+ let executableURL: URL
let bundleIdentifier: String
var launchAgentDirURL: URL {
@@ -16,15 +16,14 @@ public struct LaunchAgentManager {
launchAgentDirURL.appendingPathComponent("\(serviceIdentifier).plist").path
}
- public init(serviceIdentifier: String, executablePath: String, bundleIdentifier: String) {
+ public init(serviceIdentifier: String, executableURL: URL, bundleIdentifier: String) {
self.serviceIdentifier = serviceIdentifier
- self.executablePath = executablePath
+ self.executableURL = executableURL
self.bundleIdentifier = bundleIdentifier
}
public func setupLaunchAgentForTheFirstTimeIfNeeded() async throws {
if #available(macOS 13, *) {
- await removeObsoleteLaunchAgent()
try await setupLaunchAgent()
} else {
if UserDefaults.standard.integer(forKey: lastLaunchAgentVersionKey) < 40 {
@@ -33,48 +32,18 @@ public struct LaunchAgentManager {
}
guard !FileManager.default.fileExists(atPath: launchAgentPath) else { return }
try await setupLaunchAgent()
- await removeObsoleteLaunchAgent()
}
}
public func setupLaunchAgent() async throws {
if #available(macOS 13, *) {
- let bridgeLaunchAgent = SMAppService.agent(plistName: "bridgeLaunchAgent.plist")
- try bridgeLaunchAgent.register()
- } else {
- let content = """
-
-
-
-
- Label
- \(serviceIdentifier)
- Program
- \(executablePath)
- MachServices
-
- \(serviceIdentifier)
-
-
- AssociatedBundleIdentifiers
-
- \(bundleIdentifier)
- \(serviceIdentifier)
-
-
-
- """
- if !FileManager.default.fileExists(atPath: launchAgentDirURL.path) {
- try FileManager.default.createDirectory(
- at: launchAgentDirURL,
- withIntermediateDirectories: false
- )
+ if executableURL.path.hasPrefix("/Applications") {
+ try setupLaunchAgentWithPredefinedPlist()
+ } else {
+ try await setupLaunchAgentWithDynamicPlist()
}
- FileManager.default.createFile(
- atPath: launchAgentPath,
- contents: content.data(using: .utf8)
- )
- try await launchctl("load", launchAgentPath)
+ } else {
+ try await setupLaunchAgentWithDynamicPlist()
}
let buildNumber = (Bundle.main.infoDictionary?["CFBundleVersion"] as? String)
@@ -85,7 +54,11 @@ public struct LaunchAgentManager {
public func removeLaunchAgent() async throws {
if #available(macOS 13, *) {
let bridgeLaunchAgent = SMAppService.agent(plistName: "bridgeLaunchAgent.plist")
- try await bridgeLaunchAgent.unregister()
+ try? await bridgeLaunchAgent.unregister()
+ if FileManager.default.fileExists(atPath: launchAgentPath) {
+ try? await launchctl("unload", launchAgentPath)
+ try? FileManager.default.removeItem(atPath: launchAgentPath)
+ }
} else {
try await launchctl("unload", launchAgentPath)
try FileManager.default.removeItem(atPath: launchAgentPath)
@@ -97,23 +70,56 @@ public struct LaunchAgentManager {
try await helper("reload-launch-agent", "--service-identifier", serviceIdentifier)
}
}
+}
- public func removeObsoleteLaunchAgent() async {
- if #available(macOS 13, *) {
- let path = launchAgentPath
- if FileManager.default.fileExists(atPath: path) {
- try? await launchctl("unload", path)
- try? FileManager.default.removeItem(atPath: path)
- }
- } else {
- let path = launchAgentPath.replacingOccurrences(
- of: "ExtensionService",
- with: "XPCService"
+extension LaunchAgentManager {
+ @available(macOS 13, *)
+ func setupLaunchAgentWithPredefinedPlist() throws {
+ let bridgeLaunchAgent = SMAppService.agent(plistName: "bridgeLaunchAgent.plist")
+ try bridgeLaunchAgent.register()
+ }
+
+ func setupLaunchAgentWithDynamicPlist() async throws {
+ if FileManager.default.fileExists(atPath: launchAgentPath) {
+ throw E(errorDescription: "Launch agent already exists.")
+ }
+
+ let content = """
+
+
+
+
+ Label
+ \(serviceIdentifier)
+ Program
+ \(executableURL.path)
+ MachServices
+
+ \(serviceIdentifier)
+
+
+ AssociatedBundleIdentifiers
+
+ \(bundleIdentifier)
+ \(serviceIdentifier)
+
+
+
+ """
+ if !FileManager.default.fileExists(atPath: launchAgentDirURL.path) {
+ try FileManager.default.createDirectory(
+ at: launchAgentDirURL,
+ withIntermediateDirectories: false
)
- if FileManager.default.fileExists(atPath: path) {
- try? FileManager.default.removeItem(atPath: path)
- }
}
+ FileManager.default.createFile(
+ atPath: launchAgentPath,
+ contents: content.data(using: .utf8)
+ )
+ #if DEBUG
+ #else
+ try await launchctl("load", launchAgentPath)
+ #endif
}
}
@@ -170,7 +176,7 @@ private func launchctl(_ args: String...) async throws {
return try await process("/bin/launchctl", args)
}
-struct E: Error, LocalizedError {
+private struct E: Error, LocalizedError {
var errorDescription: String?
}
diff --git a/Core/Sources/ChatPlugin/AskChatGPT.swift b/Core/Sources/LegacyChatPlugin/AskChatGPT.swift
similarity index 81%
rename from Core/Sources/ChatPlugin/AskChatGPT.swift
rename to Core/Sources/LegacyChatPlugin/AskChatGPT.swift
index e95deac9..b942a7de 100644
--- a/Core/Sources/ChatPlugin/AskChatGPT.swift
+++ b/Core/Sources/LegacyChatPlugin/AskChatGPT.swift
@@ -12,9 +12,10 @@ public func askChatGPT(
let memory = AutoManagedChatGPTMemory(
systemPrompt: systemPrompt,
configuration: configuration,
- functionProvider: NoChatGPTFunctionProvider()
+ functionProvider: NoChatGPTFunctionProvider(),
+ maxNumberOfMessages: .max
)
- let service = ChatGPTService(
+ let service = LegacyChatGPTService(
memory: memory,
configuration: configuration
)
diff --git a/Core/Sources/ChatPlugin/CallAIFunction.swift b/Core/Sources/LegacyChatPlugin/CallAIFunction.swift
similarity index 87%
rename from Core/Sources/ChatPlugin/CallAIFunction.swift
rename to Core/Sources/LegacyChatPlugin/CallAIFunction.swift
index e29a4d31..20f7a01d 100644
--- a/Core/Sources/ChatPlugin/CallAIFunction.swift
+++ b/Core/Sources/LegacyChatPlugin/CallAIFunction.swift
@@ -18,11 +18,12 @@ func callAIFunction(
let argsString = args.joined(separator: ", ")
let configuration = UserPreferenceChatGPTConfiguration()
.overriding(.init(temperature: 0))
- let service = ChatGPTService(
+ let service = LegacyChatGPTService(
memory: AutoManagedChatGPTMemory(
systemPrompt: "You are now the following python function: ```# \(description)\n\(function)```\n\nOnly respond with your `return` value.",
configuration: configuration,
- functionProvider: NoChatGPTFunctionProvider()
+ functionProvider: NoChatGPTFunctionProvider(),
+ maxNumberOfMessages: .max
),
configuration: configuration
)
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 89%
rename from Core/Sources/ChatPlugin/TerminalChatPlugin.swift
rename to Core/Sources/LegacyChatPlugin/TerminalChatPlugin.swift
index 285b2947..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 ChatGPTServiceType
+ let chatGPTService: any LegacyChatGPTServiceType
var terminal: TerminalType = Terminal()
var isCancelled = false
- weak var delegate: ChatPluginDelegate?
+ weak var delegate: LegacyChatPluginDelegate?
- public init(inside chatGPTService: any ChatGPTServiceType, 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 ca39a2fc..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 ChatGPTServiceType)?
+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)
+ }
+
+ continuation.finish()
+ } catch {
+ continuation.finish(throwing: error)
+ }
+ }
- public func stopResponding() {
- Task { await service?.stopReceivingMessage() }
+ 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,49 +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 = ChatGPTService(
- memory: memory,
- configuration: configuration
+ 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 609af1d4..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,76 +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 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 396145f9..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
@@ -21,10 +18,10 @@ import ChatTabPersistent
@Reducer
struct GUI {
@ObservableState
- struct State: Equatable {
- var suggestionWidgetState = WidgetFeature.State()
+ struct State {
+ var suggestionWidgetState = Widget.State()
- var chatTabGroup: ChatPanelFeature.ChatTabGroup {
+ var chatTabGroup: SuggestionWidget.ChatPanel.ChatTabGroup {
get { suggestionWidgetState.chatPanelState.chatTabGroup }
set { suggestionWidgetState.chatPanelState.chatTabGroup = newValue }
}
@@ -55,7 +52,7 @@ struct GUI {
enum Action {
case start
- case openChatPanel(forceDetach: Bool)
+ case openChatPanel(forceDetach: Bool, activateThisApp: Bool)
case createAndSwitchToChatGPTChatTabIfNeeded
case createAndSwitchToChatTabIfNeededMatching(
check: (any ChatTab) -> Bool,
@@ -64,7 +61,7 @@ struct GUI {
case sendCustomCommandToActiveChat(CustomCommand)
case toggleWidgetsHotkeyPressed
- case suggestionWidget(WidgetFeature.Action)
+ case suggestionWidget(Widget.Action)
static func promptToCodeGroup(_ action: PromptToCodeGroup.Action) -> Self {
.suggestionWidget(.panel(.sharedPanel(.promptToCodeGroup(action))))
@@ -85,7 +82,7 @@ struct GUI {
var body: some ReducerOf {
CombineReducers {
Scope(state: \.suggestionWidgetState, action: \.suggestionWidget) {
- WidgetFeature()
+ Widget()
}
Scope(
@@ -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)
@@ -138,7 +135,7 @@ struct GUI {
return .none
#endif
- case let .openChatPanel(forceDetach):
+ case let .openChatPanel(forceDetach, activate):
return .run { send in
await send(
.suggestionWidget(
@@ -147,7 +144,9 @@ struct GUI {
)
await send(.suggestionWidget(.updateKeyWindow(.chatPanel)))
- activateThisApp()
+ if activate {
+ activateThisApp()
+ }
}
case .createAndSwitchToChatGPTChatTabIfNeeded:
@@ -188,44 +187,35 @@ 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)
- }
-
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))
- await stopAndHandleCommand(activeTab)
+ await send(.openChatPanel(forceDetach: false, activateThisApp: false))
}
}
- 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))
- 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
+ ))
+ }
}
}
return .run { send in
guard let (chatTab, chatTabInfo) = await chatTabPool.createTab(for: nil)
- else {
- return
- }
+ else { return }
await send(.suggestionWidget(.chatPanel(.appendAndSelectTab(chatTabInfo))))
- await send(.openChatPanel(forceDetach: false))
- if let chatTab = chatTab as? ChatGPTChatTab {
- await stopAndHandleCommand(chatTab)
- }
+ await send(.openChatPanel(forceDetach: false, activateThisApp: false))
+ _ = 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,17 +288,6 @@ public final class GraphicalUserInterfaceController {
dependencies.suggestionWidgetUserDefaultsObservers = .init()
dependencies.chatTabPool = chatTabPool
dependencies.chatTabBuilderCollection = ChatTabFactory.chatTabBuilderCollection
- dependencies.promptToCodeAcceptHandler = { promptToCode in
- Task {
- let handler = PseudoCommandHandler()
- await handler.acceptPromptToCode()
- if !promptToCode.isContinuous {
- NSWorkspace.activatePreviousActiveXcode()
- } else {
- NSWorkspace.activateThisApp()
- }
- }
- }
#if canImport(ChatTabPersistent) && canImport(ProChatTabs)
dependencies.restoreChatTabInPool = {
@@ -340,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))
+ 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
@@ -378,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)
}
@@ -388,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/GUI/WidgetDataSource.swift b/Core/Sources/Service/GUI/WidgetDataSource.swift
index 77dd8993..ae1b6371 100644
--- a/Core/Sources/Service/GUI/WidgetDataSource.swift
+++ b/Core/Sources/Service/GUI/WidgetDataSource.swift
@@ -14,7 +14,7 @@ import SuggestionWidget
final class WidgetDataSource {}
extension WidgetDataSource: SuggestionWidgetDataSource {
- func suggestionForFile(at url: URL) async -> CodeSuggestionProvider? {
+ func suggestionForFile(at url: URL) async -> PresentingCodeSuggestion? {
for workspace in Service.shared.workspacePool.workspaces.values {
if let filespace = workspace.filespaces[url],
let suggestion = filespace.presentingSuggestion
@@ -25,39 +25,9 @@ extension WidgetDataSource: SuggestionWidgetDataSource {
startLineIndex: suggestion.position.line,
suggestionCount: filespace.suggestions.count,
currentSuggestionIndex: filespace.suggestionIndex,
- onSelectPreviousSuggestionTapped: {
- Task {
- let handler = PseudoCommandHandler()
- await handler.presentPreviousSuggestion()
- }
- },
- onSelectNextSuggestionTapped: {
- Task {
- let handler = PseudoCommandHandler()
- await handler.presentNextSuggestion()
- }
- },
- onRejectSuggestionTapped: {
- Task {
- let handler = PseudoCommandHandler()
- await handler.rejectSuggestions()
- NSWorkspace.activatePreviousActiveXcode()
- }
- },
- onAcceptSuggestionTapped: {
- Task {
- let handler = PseudoCommandHandler()
- await handler.acceptSuggestion()
- NSWorkspace.activatePreviousActiveXcode()
- }
- },
- onDismissSuggestionTapped: {
- Task {
- let handler = PseudoCommandHandler()
- await handler.dismissSuggestion()
- NSWorkspace.activatePreviousActiveXcode()
- }
- }
+ replacingRange: suggestion.range,
+ replacingLines: suggestion.replacingLines,
+ descriptions: suggestion.descriptions
)
}
}
diff --git a/Core/Sources/Service/GlobalShortcutManager.swift b/Core/Sources/Service/GlobalShortcutManager.swift
index 9620f25a..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
@@ -28,28 +28,36 @@ final class GlobalShortcutManager {
!guiController.store.state.suggestionWidgetState.chatPanelState.isPanelDisplayed,
UserDefaults.shared.value(for: \.showHideWidgetShortcutGlobally)
{
- guiController.store.send(.openChatPanel(forceDetach: true))
+ guiController.store.send(.openChatPanel(forceDetach: true, activateThisApp: true))
} else {
guiController.store.send(.toggleWidgetsHotkeyPressed)
}
}
- 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 06d6f522..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)
{
@@ -163,10 +164,10 @@ public actor RealtimeSuggestionController {
func cancelInFlightTasks(excluding: Task? = nil) async {
inflightPrefetchTask?.cancel()
-
+ let workspaces = await Service.shared.workspacePool.workspaces
// cancel in-flight tasks
await withTaskGroup(of: Void.self) { group in
- for (_, workspace) in Service.shared.workspacePool.workspaces {
+ for (_, workspace) in workspaces {
group.addTask {
await workspace.cancelInFlightRealtimeSuggestionRequests()
}
@@ -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 35ca6a50..b1702924 100644
--- a/Core/Sources/Service/Service.swift
+++ b/Core/Sources/Service/Service.swift
@@ -1,11 +1,13 @@
import BuiltinExtension
import CodeiumService
import Combine
+import CommandHandler
import Dependencies
import Foundation
import GitHubCopilotService
import KeyBindingManager
import Logger
+import OverlayWindow
import SuggestionService
import Toast
import Workspace
@@ -24,17 +26,19 @@ import ProService
/// The running extension service.
public final class Service {
+ @MainActor
public static let shared = Service()
- @WorkspaceActor
- let workspacePool: WorkspacePool
+ @Dependency(\.workspacePool) var workspacePool
@MainActor
- public let guiController = GraphicalUserInterfaceController()
- public let realtimeSuggestionController = RealtimeSuggestionController()
+ public let guiController: GraphicalUserInterfaceController
+ public let commandHandler: CommandHandler
+ public let realtimeSuggestionController: RealtimeSuggestionController
public let scheduledCleaner: ScheduledCleaner
let globalShortcutManager: GlobalShortcutManager
let keyBindingManager: KeyBindingManager
let xcodeThemeController: XcodeThemeController = .init()
+ let overlayWindowController: OverlayWindowController
#if canImport(ProService)
let proService: ProService
@@ -43,14 +47,31 @@ public final class Service {
@Dependency(\.toast) var toast
var cancellable = Set()
+ @MainActor
private init() {
@Dependency(\.workspacePool) var workspacePool
+ let commandHandler = PseudoCommandHandler()
+ UniversalCommandHandler.shared.commandHandler = commandHandler
+ self.commandHandler = commandHandler
+
+ realtimeSuggestionController = .init()
+ scheduledCleaner = .init()
+ overlayWindowController = .init()
+
+ #if canImport(ProService)
+ proService = ProService()
+ #endif
- BuiltinExtensionManager.shared.setupExtensions([
+ BuiltinExtensionManager.shared.addExtensions([
GitHubCopilotExtension(workspacePool: workspacePool),
CodeiumExtension(workspacePool: workspacePool),
])
- scheduledCleaner = .init()
+
+ let guiController = GraphicalUserInterfaceController()
+ self.guiController = guiController
+ globalShortcutManager = .init(guiController: guiController)
+ keyBindingManager = .init()
+
workspacePool.registerPlugin {
SuggestionServiceWorkspacePlugin(workspace: $0) { SuggestionService.service() }
}
@@ -63,21 +84,6 @@ public final class Service {
workspacePool.registerPlugin {
BuiltinExtensionWorkspacePlugin(workspace: $0)
}
- self.workspacePool = workspacePool
- globalShortcutManager = .init(guiController: guiController)
- keyBindingManager = .init(
- workspacePool: workspacePool,
- acceptSuggestion: {
- Task { await PseudoCommandHandler().acceptSuggestion() }
- },
- dismissSuggestion: {
- Task { await PseudoCommandHandler().dismissSuggestion() }
- }
- )
-
- #if canImport(ProService)
- proService = ProService()
- #endif
scheduledCleaner.service = self
}
@@ -91,21 +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 { [weak self] fileURL in
- Task {
- try await self?.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)
+ }
+ }
}
}
@@ -128,12 +138,44 @@ public extension Service {
) {
do {
#if canImport(ProService)
- try Service.shared.proService.handleXPCServiceRequests(
+ try proService.handleXPCServiceRequests(
endpoint: endpoint,
requestBody: requestBody,
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 6afc5956..c1d38d78 100644
--- a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift
+++ b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift
@@ -1,13 +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 SuggestionInjector
+import PromptToCodeCustomization
import SuggestionBasic
+import SuggestionInjector
+import Terminal
import Toast
import Workspace
import WorkspaceSuggestionService
@@ -21,7 +27,7 @@ import BrowserChatTab
/// It's used to run some commands without really triggering the menu bar item.
///
/// For example, we can use it to generate real-time suggestions without Apple Scripts.
-struct PseudoCommandHandler {
+struct PseudoCommandHandler: CommandHandler {
static var lastTimeCommandFailedToTriggerWithAccessibilityAPI = Date(timeIntervalSince1970: 0)
private var toast: ToastController { ToastControllerDependencyKey.liveValue }
@@ -86,7 +92,7 @@ struct PseudoCommandHandler {
}
let snapshot = FilespaceSuggestionSnapshot(
- linesHash: editor.lines.hashValue,
+ lines: editor.lines,
cursorPosition: editor.cursorPosition
)
@@ -181,7 +187,7 @@ struct PseudoCommandHandler {
}
}() else {
do {
- try await XcodeInspector.shared.safe.latestActiveXcode?
+ try await XcodeInspector.shared.latestActiveXcode?
.triggerCopilotCommand(name: command.name)
} catch {
let presenter = PresentInWindowSuggestionPresenter()
@@ -199,14 +205,91 @@ struct PseudoCommandHandler {
}
}
- func acceptPromptToCode() async {
+ func acceptModification() async {
+ do {
+ if UserDefaults.shared.value(for: \.alwaysAcceptSuggestionWithAccessibilityAPI) {
+ throw CancellationError()
+ }
+ do {
+ try await XcodeInspector.shared.latestActiveXcode?
+ .triggerCopilotCommand(name: "Accept Modification")
+ } catch {
+ do {
+ try await XcodeInspector.shared.latestActiveXcode?
+ .triggerCopilotCommand(name: "Accept Prompt to Code")
+ } 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.acceptPromptToCode(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 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.safe.latestActiveXcode?
- .triggerCopilotCommand(name: "Accept Prompt to Code")
+ try await XcodeInspector.shared.latestActiveXcode?
+ .triggerCopilotCommand(name: "Accept Suggestion Line")
} catch {
let last = Self.lastTimeCommandFailedToTriggerWithAccessibilityAPI
let now = Date()
@@ -216,7 +299,7 @@ struct PseudoCommandHandler {
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
@@ -242,7 +325,7 @@ struct PseudoCommandHandler {
}
let handler = WindowBaseCommandHandler()
do {
- guard let result = try await handler.acceptPromptToCode(editor: .init(
+ guard let result = try await handler.acceptSuggestion(editor: .init(
content: content,
lines: lines,
uti: "",
@@ -267,7 +350,7 @@ struct PseudoCommandHandler {
throw CancellationError()
}
do {
- try await XcodeInspector.shared.safe.latestActiveXcode?
+ try await XcodeInspector.shared.latestActiveXcode?
.triggerCopilotCommand(name: "Accept Suggestion")
} catch {
let last = Self.lastTimeCommandFailedToTriggerWithAccessibilityAPI
@@ -278,7 +361,7 @@ struct PseudoCommandHandler {
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
@@ -324,21 +407,41 @@ struct PseudoCommandHandler {
}
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 }
-
await filespace.reset()
- PresentInWindowSuggestionPresenter().discardSuggestion(fileURL: documentURL)
}
- func openChat(forceDetach: Bool) {
+ func openChat(forceDetach: Bool, activateThisApp: Bool = true) {
switch UserDefaults.shared.value(for: \.openChatMode) {
case .chatPanel:
- let store = Service.shared.guiController.store
+ 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()
- store.send(.openChatPanel(forceDetach: forceDetach))
+ store.send(.openChatPanel(
+ forceDetach: forceDetach,
+ activateThisApp: activateThisApp
+ ))
}
case .browser:
let urlString = UserDefaults.shared.value(for: \.openChatInBrowserURL)
@@ -359,8 +462,8 @@ struct PseudoCommandHandler {
if openInApp {
#if canImport(BrowserChatTab)
- let store = Service.shared.guiController.store
Task { @MainActor in
+ let store = Service.shared.guiController.store
await store.send(.createAndSwitchToChatTabIfNeededMatching(
check: {
func match(_ tabURL: URL?) -> Bool {
@@ -375,26 +478,111 @@ struct PseudoCommandHandler {
},
kind: .init(BrowserChatTab.urlChatBuilder(url: url))
)).finish()
- store.send(.openChatPanel(forceDetach: forceDetach))
+ store.send(.openChatPanel(
+ forceDetach: forceDetach,
+ activateThisApp: activateThisApp
+ ))
}
#endif
} else {
Task {
- @Dependency(\.openURL) var openURL
- await openURL(url)
+ NSWorkspace.shared.open(url)
}
}
- case .codeiumChat:
- let store = Service.shared.guiController.store
+ 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))
+ 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(
+ forceDetach: forceDetach,
+ activateThisApp: activateThisApp
+ ))
+ }
+ }
+ }
+
+ @MainActor
+ func sendChatMessage(_ message: String) async {
+ let store = Service.shared.guiController.store
+ await store.send(.sendCustomCommandToActiveChat(CustomCommand(
+ commandId: "",
+ name: "",
+ feature: .chatWithSelection(
+ extraSystemPrompt: nil,
+ prompt: message,
+ useExtraSystemPrompt: nil
+ ),
+ ignoreExistingAttachments: false,
+ attachments: []
+ ))).finish()
+ }
+
+ @WorkspaceActor
+ func presentSuggestions(_ suggestions: [SuggestionBasic.CodeSuggestion]) async {
+ guard let filespace = await getFilespace() else { return }
+ filespace.setSuggestions(suggestions)
+ PresentInWindowSuggestionPresenter().presentSuggestion(fileURL: filespace.fileURL)
+ }
+
+ func toast(_ message: String, as type: ToastType) {
+ Task { @MainActor in
+ let store = Service.shared.guiController.store
+ 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)
}
}
}
@@ -422,7 +610,7 @@ extension PseudoCommandHandler {
// recover selection range
- if let selection = result.newSelection {
+ if let selection = result.newSelections.first {
var range = SourceEditor.convertCursorRangeToRange(selection, in: result.content)
if let value = AXValueCreate(.cfRange, &range) {
AXUIElementSetAttributeValue(
@@ -481,7 +669,7 @@ extension PseudoCommandHandler {
}
func getFileURL() async -> URL? {
- await XcodeInspector.shared.safe.realtimeActiveDocumentURL
+ XcodeInspector.shared.realtimeActiveDocumentURL
}
@WorkspaceActor
@@ -499,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 }
@@ -522,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 eb58241b..53b0c833 100644
--- a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift
+++ b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift
@@ -1,12 +1,15 @@
import AppKit
import ChatService
+import ComposableArchitecture
+import CustomCommandTemplateProcessor
import Foundation
import GitHubCopilotService
import LanguageServerProtocol
import Logger
+import ModificationBasic
import OpenAIService
-import SuggestionInjector
import SuggestionBasic
+import SuggestionInjector
import SuggestionWidget
import UserNotifications
import Workspace
@@ -39,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)
@@ -73,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)
@@ -99,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)
@@ -125,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
@@ -136,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)
@@ -169,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()
@@ -178,58 +207,69 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler {
var cursorPosition = editor.cursorPosition
var extraInfo = SuggestionInjector.ExtraInfo()
- let store = Service.shared.guiController.store
+ let store = await Service.shared.guiController.store
- if let promptToCode = store.state.promptToCodeGroup.activePromptToCode {
- if promptToCode.isAttachedToSelectionRange, promptToCode.documentURL != fileURL {
+ if let promptToCode = await MainActor
+ .run(body: { store.state.promptToCodeGroup.activePromptToCode })
+ {
+ if promptToCode.promptToCodeState.isAttachedToTarget,
+ promptToCode.promptToCodeState.source.documentURL != fileURL
+ {
return nil
}
- let range = {
- if promptToCode.isAttachedToSelectionRange,
- let range = promptToCode.selectionRange
- {
- return range
+ let suggestions = promptToCode.promptToCodeState.snippets
+ .map { snippet in
+ let range = {
+ if promptToCode.promptToCodeState.isAttachedToTarget {
+ return snippet.attachedRange
+ }
+ return editor.selections.first.map {
+ CursorRange(start: $0.start, end: $0.end)
+ } ?? CursorRange(
+ start: editor.cursorPosition,
+ end: editor.cursorPosition
+ )
+ }()
+ return CodeSuggestion(
+ id: snippet.id.uuidString,
+ text: snippet.modifiedCode,
+ position: range.start,
+ range: range
+ )
}
- return editor.selections.first.map {
- CursorRange(start: $0.start, end: $0.end)
- } ?? CursorRange(
- start: editor.cursorPosition,
- end: editor.cursorPosition
- )
- }()
-
- let suggestion = CodeSuggestion(
- id: UUID().uuidString,
- text: promptToCode.code,
- position: range.start,
- range: range
- )
- injector.acceptSuggestion(
+ injector.acceptSuggestions(
intoContentWithoutSuggestion: &lines,
cursorPosition: &cursorPosition,
- completion: suggestion,
+ completions: suggestions,
extraInfo: &extraInfo
)
- _ = await Task { @MainActor [cursorPosition] in
- store.send(
- .promptToCodeGroup(.updatePromptToCodeRange(
- id: promptToCode.id,
- range: .init(start: range.start, end: cursorPosition)
- ))
- )
+ for (id, range) in extraInfo.modificationRanges {
+ _ = await MainActor.run {
+ store.send(
+ .promptToCodeGroup(.updatePromptToCodeRange(
+ id: promptToCode.id,
+ snippetId: .init(uuidString: id) ?? .init(),
+ range: range
+ ))
+ )
+ }
+ }
+
+ _ = await MainActor.run {
store.send(
.promptToCodeGroup(.discardAcceptedPromptToCodeIfNotContinuous(
id: promptToCode.id
))
)
- }.result
+ }
return .init(
content: String(lines.joined(separator: "")),
- newSelection: .init(start: range.start, end: cursorPosition),
+ newSelections: extraInfo.modificationRanges.values
+ .sorted(by: { $0.start.line <= $1.start.line }),
modifications: extraInfo.modifications
)
}
@@ -246,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)
@@ -341,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")
@@ -352,11 +392,31 @@ extension WindowBaseCommandHandler {
let codeLanguage = languageIdentifierFromFileURL(fileURL)
- let (code, selection) = {
- guard var selection = editor.selections.last,
- selection.start != selection.end
- else { return ("", .cursor(editor.cursorPosition)) }
-
+ let selections: [CursorRange] = {
+ if let firstSelection = editor.selections.first,
+ let lastSelection = editor.selections.last
+ {
+ let range = CursorRange(
+ start: firstSelection.start,
+ end: lastSelection.end
+ )
+ return [range]
+ }
+ return []
+ }()
+
+ let snippets = selections.map { selection in
+ guard selection.start != selection.end else {
+ return ModificationSnippet(
+ startLineIndex: selection.start.line,
+ originalCode: "",
+ modifiedCode: "",
+ description: "",
+ error: "",
+ attachedRange: selection
+ )
+ }
+ var selection = selection
let isMultipleLine = selection.start.line != selection.end.line
let isSpaceOnlyBeforeStartPositionOnTheSameLine = {
guard selection.start.line >= 0, selection.start.line < editor.lines.count else {
@@ -379,16 +439,21 @@ extension WindowBaseCommandHandler {
// indentation.
selection.start = .init(line: selection.start.line, character: 0)
}
- return (
- editor.selectedCode(in: selection),
- .init(
- start: .init(line: selection.start.line, character: selection.start.character),
- end: .init(line: selection.end.line, character: selection.end.character)
- )
+ let selectedCode = editor.selectedCode(in: .init(
+ start: selection.start,
+ end: selection.end
+ ))
+ return ModificationSnippet(
+ startLineIndex: selection.start.line,
+ originalCode: selectedCode,
+ modifiedCode: selectedCode,
+ description: "",
+ error: "",
+ attachedRange: .init(start: selection.start, end: selection.end)
)
- }() as (String, CursorRange)
+ }
- let store = Service.shared.guiController.store
+ let store = await Service.shared.guiController.store
let customCommandTemplateProcessor = CustomCommandTemplateProcessor()
@@ -404,25 +469,25 @@ extension WindowBaseCommandHandler {
nil
}
- _ = await Task { @MainActor in
- // if there is already a prompt to code presenting, we should not present another one
+ _ = await MainActor.run {
store.send(.promptToCodeGroup(.activateOrCreatePromptToCode(.init(
- code: code,
- selectionRange: selection,
- language: codeLanguage,
- identSize: filespace.codeMetadata.indentSize ?? 4,
- usesTabsForIndentation: filespace.codeMetadata.usesTabsForIndentation ?? false,
- documentURL: fileURL,
- projectRootURL: workspace.projectRootURL,
- allCode: editor.content,
- allLines: editor.lines,
- isContinuous: isContinuous,
+ promptToCodeState: Shared(.init(
+ source: .init(
+ language: codeLanguage,
+ documentURL: fileURL,
+ projectRootURL: workspace.projectRootURL,
+ content: editor.content,
+ lines: editor.lines
+ ),
+ snippets: IdentifiedArray(uniqueElements: snippets),
+ extraSystemPrompt: newExtraSystemPrompt ?? "",
+ isAttachedToTarget: true
+ )),
+ instruction: newPrompt,
commandName: name,
- defaultPrompt: newPrompt ?? "",
- extraSystemPrompt: newExtraSystemPrompt,
- generateDescriptionRequirement: generateDescription
+ isContinuous: isContinuous
))))
- }.result
+ }
}
func executeSingleRoundDialog(
diff --git a/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift b/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift
index ce2eb039..7069422b 100644
--- a/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift
+++ b/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift
@@ -42,17 +42,10 @@ struct PresentInWindowSuggestionPresenter {
}
}
- func closeChatRoom(fileURL: URL) {
- Task { @MainActor in
- let controller = Service.shared.guiController.widgetController
- controller.closeChatRoom()
- }
- }
-
func presentChatRoom(fileURL: URL) {
Task { @MainActor in
- let controller = Service.shared.guiController.widgetController
- controller.presentChatRoom()
+ let controller = Service.shared.guiController
+ controller.store.send(.openChatPanel(forceDetach: false, activateThisApp: true))
}
}
}
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/SuggestionService/SuggestionService.swift b/Core/Sources/SuggestionService/SuggestionService.swift
index 2d6cc2da..335f0c83 100644
--- a/Core/Sources/SuggestionService/SuggestionService.swift
+++ b/Core/Sources/SuggestionService/SuggestionService.swift
@@ -1,7 +1,7 @@
import BuiltinExtension
import CodeiumService
-import struct CopilotForXcodeKit.WorkspaceInfo
import enum CopilotForXcodeKit.SuggestionServiceError
+import struct CopilotForXcodeKit.WorkspaceInfo
import Foundation
import GitHubCopilotService
import Preferences
@@ -17,21 +17,25 @@ import ProExtension
public protocol SuggestionServiceType: SuggestionServiceProvider {}
public actor SuggestionService: SuggestionServiceType {
+ public typealias Middleware = SuggestionServiceMiddleware
+ public typealias EventHandler = SuggestionServiceEventHandler
public var configuration: SuggestionProvider.SuggestionServiceConfiguration {
get async { await suggestionProvider.configuration }
}
- let middlewares: [SuggestionServiceMiddleware]
+ let middlewares: [Middleware]
+ let eventHandlers: [EventHandler]
let suggestionProvider: SuggestionServiceProvider
public init(
provider: any SuggestionServiceProvider,
- middlewares: [SuggestionServiceMiddleware] = SuggestionServiceMiddlewareContainer
- .middlewares
+ middlewares: [Middleware] = SuggestionServiceMiddlewareContainer.middlewares,
+ eventHandlers: [EventHandler] = SuggestionServiceEventHandlerContainer.handlers
) {
suggestionProvider = provider
self.middlewares = middlewares
+ self.eventHandlers = eventHandlers
}
public static func service(
@@ -67,7 +71,7 @@ public extension SuggestionService {
do {
var getSuggestion = suggestionProvider.getSuggestions(_:workspaceInfo:)
let configuration = await configuration
-
+
for middleware in middlewares.reversed() {
getSuggestion = { [getSuggestion] request, workspaceInfo in
try await middleware.getSuggestion(
@@ -79,7 +83,7 @@ public extension SuggestionService {
)
}
}
-
+
return try await getSuggestion(request, workspaceInfo)
} catch let error as SuggestionServiceError {
throw error
@@ -92,6 +96,7 @@ public extension SuggestionService {
_ suggestion: SuggestionBasic.CodeSuggestion,
workspaceInfo: CopilotForXcodeKit.WorkspaceInfo
) async {
+ eventHandlers.forEach { $0.didAccept(suggestion, workspaceInfo: workspaceInfo) }
await suggestionProvider.notifyAccepted(suggestion, workspaceInfo: workspaceInfo)
}
@@ -99,6 +104,7 @@ public extension SuggestionService {
_ suggestions: [SuggestionBasic.CodeSuggestion],
workspaceInfo: CopilotForXcodeKit.WorkspaceInfo
) async {
+ eventHandlers.forEach { $0.didReject(suggestions, workspaceInfo: workspaceInfo) }
await suggestionProvider.notifyRejected(suggestions, workspaceInfo: workspaceInfo)
}
diff --git a/Core/Sources/SuggestionWidget/ChatPanelWindow.swift b/Core/Sources/SuggestionWidget/ChatPanelWindow.swift
index 35590f2b..022b424c 100644
--- a/Core/Sources/SuggestionWidget/ChatPanelWindow.swift
+++ b/Core/Sources/SuggestionWidget/ChatPanelWindow.swift
@@ -9,46 +9,33 @@ final class ChatPanelWindow: WidgetWindow {
override var canBecomeMain: Bool { true }
private let storeObserver = NSObject()
+ private let store: StoreOf
var minimizeWindow: () -> Void = {}
- override var defaultCollectionBehavior: NSWindow.CollectionBehavior {
- [
- .fullScreenAuxiliary,
- .transient,
- .fullScreenPrimary,
- .fullScreenAllowsTiling,
- ]
+ var isDetached: Bool {
+ store.withState { $0.isDetached }
}
- override var switchingSpaceCollectionBehavior: NSWindow.CollectionBehavior {
- [
- .fullScreenAuxiliary,
- .transient,
- .fullScreenPrimary,
- .fullScreenAllowsTiling,
- ]
- }
-
- override var fullscreenCollectionBehavior: NSWindow.CollectionBehavior {
+ override var defaultCollectionBehavior: NSWindow.CollectionBehavior {
[
.fullScreenAuxiliary,
.transient,
.fullScreenPrimary,
.fullScreenAllowsTiling,
- .canJoinAllSpaces,
]
}
init(
- store: StoreOf,
+ store: StoreOf,
chatTabPool: ChatTabPool,
minimizeWindow: @escaping () -> Void
) {
+ self.store = store
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
)
@@ -64,10 +51,7 @@ final class ChatPanelWindow: WidgetWindow {
}())
titlebarAppearsTransparent = true
isReleasedWhenClosed = false
- isOpaque = false
- backgroundColor = .clear
level = widgetLevel(1)
-
hasShadow = true
contentView = NSHostingView(
rootView: ChatWindowView(
@@ -97,22 +81,12 @@ final class ChatPanelWindow: WidgetWindow {
}
}
}
-
+
func centerInActiveSpaceIfNeeded() {
guard !isOnActiveSpace else { return }
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
@@ -134,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 06095086..58c6f4d7 100644
--- a/Core/Sources/SuggestionWidget/ChatWindowView.swift
+++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift
@@ -4,11 +4,12 @@ import ChatGPTChatTab
import ChatTab
import ComposableArchitecture
import SwiftUI
+import SharedUIComponents
private let r: Double = 8
struct ChatWindowView: View {
- let store: StoreOf
+ let store: StoreOf
let toggleVisibility: (Bool) -> Void
var body: some View {
@@ -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)
@@ -38,7 +40,7 @@ struct ChatWindowView: View {
}
struct ChatTitleBar: View {
- let store: StoreOf
+ let store: StoreOf
@State var isHovering = false
var body: some View {
@@ -125,7 +127,7 @@ struct ChatTitleBar: View {
}
}
-private extension View {
+extension View {
func hideScrollIndicator() -> some View {
if #available(macOS 13.0, *) {
return scrollIndicators(.hidden)
@@ -136,7 +138,7 @@ private extension View {
}
struct ChatTabBar: View {
- let store: StoreOf
+ let store: StoreOf
struct TabBarState: Equatable {
var tabInfo: IdentifiedArray
@@ -160,7 +162,7 @@ struct ChatTabBar: View {
}
struct Tabs: View {
- let store: StoreOf
+ let store: StoreOf
@State var draggingTabId: String?
@Environment(\.chatTabPool) var chatTabPool
@@ -200,7 +202,7 @@ struct ChatTabBar: View {
draggingTabId: $draggingTabId
)
)
-
+
} else {
ChatTabBarButton(
store: store,
@@ -226,7 +228,7 @@ struct ChatTabBar: View {
}
struct CreateButton: View {
- let store: StoreOf
+ let store: StoreOf
var body: some View {
WithPerceptionTracking {
@@ -278,7 +280,7 @@ struct ChatTabBar: View {
}
struct ChatTabBarDropDelegate: DropDelegate {
- let store: StoreOf
+ let store: StoreOf
let tabs: IdentifiedArray
let itemId: String
@Binding var draggingTabId: String?
@@ -302,7 +304,7 @@ struct ChatTabBarDropDelegate: DropDelegate {
}
struct ChatTabBarButton: View {
- let store: StoreOf
+ let store: StoreOf
let info: ChatTabInfo
let content: () -> Content
let icon: () -> Icon
@@ -347,7 +349,7 @@ struct ChatTabBarButton: View {
}
struct ChatTabContainer: View {
- let store: StoreOf
+ let store: StoreOf
@Environment(\.chatTabPool) var chatTabPool
var body: some View {
@@ -406,8 +408,8 @@ struct ChatWindowView_Previews: PreviewProvider {
"7": EmptyChatTab(id: "7"),
])
- static func createStore() -> StoreOf {
- StoreOf(
+ static func createStore() -> StoreOf {
+ StoreOf(
initialState: .init(
chatTabGroup: .init(
tabInfo: [
@@ -422,7 +424,7 @@ struct ChatWindowView_Previews: PreviewProvider {
),
isPanelDisplayed: true
),
- reducer: { ChatPanelFeature() }
+ reducer: { ChatPanel() }
)
}
diff --git a/Core/Sources/SuggestionWidget/CursorPositionTracker.swift b/Core/Sources/SuggestionWidget/CursorPositionTracker.swift
deleted file mode 100644
index 35f74326..00000000
--- a/Core/Sources/SuggestionWidget/CursorPositionTracker.swift
+++ /dev/null
@@ -1,53 +0,0 @@
-import Combine
-import Foundation
-import Perception
-import SuggestionBasic
-import XcodeInspector
-
-@Perceptible
-final class CursorPositionTracker {
- @MainActor
- var cursorPosition: CursorPosition = .zero
-
- @PerceptionIgnored var editorObservationTask: Set = []
- @PerceptionIgnored var eventObservationTask: Task?
-
- init() {
- observeAppChange()
- }
-
- deinit {
- eventObservationTask?.cancel()
- }
-
- private func observeAppChange() {
- 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)
- }
- }
-
- private func observeAXNotifications(_ editor: SourceEditor) {
- eventObservationTask?.cancel()
- let content = editor.getLatestEvaluatedContent()
- Task { @MainActor in
- self.cursorPosition = content.cursorPosition
- }
- eventObservationTask = Task { [weak self] in
- for await event in await editor.axNotifications.notifications() {
- guard let self else { return }
- guard event.kind == .evaluatedContentChanged else { continue }
- let content = editor.getLatestEvaluatedContent()
- Task { @MainActor in
- self.cursorPosition = content.cursorPosition
- }
- }
- }
- }
-}
-
diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanel.swift
similarity index 96%
rename from Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift
rename to Core/Sources/SuggestionWidget/FeatureReducers/ChatPanel.swift
index 4707c56f..28bf5bfc 100644
--- a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift
+++ b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanel.swift
@@ -23,7 +23,7 @@ public struct ChatTabKind: Equatable {
}
@Reducer
-public struct ChatPanelFeature {
+public struct ChatPanel {
public struct ChatTabGroup: Equatable {
public var tabInfo: IdentifiedArray
public var tabCollection: [ChatTabBuilderCollection]
@@ -77,9 +77,10 @@ public struct ChatPanelFeature {
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
@@ -166,7 +167,6 @@ public struct ChatPanelFeature {
.chatPanelWindow
.centerInActiveSpaceIfNeeded()
}
- activateExtensionService()
await send(.focusActiveChatTab)
}
@@ -198,6 +198,7 @@ public struct ChatPanelFeature {
return max(nextIndex, 0)
}()
state.chatTabGroup.tabInfo.removeAll { $0.id == id }
+ chatTabPool.getTab(of: id)?.close()
if state.chatTabGroup.tabInfo.isEmpty {
state.isPanelDisplayed = false
}
@@ -279,10 +280,10 @@ public struct ChatPanelFeature {
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))
}
@@ -290,7 +291,7 @@ public struct ChatPanelFeature {
case .chatTab:
return .none
}
- }.forEach(\.chatTabGroup.tabInfo, action: /Action.chatTab) {
+ }.forEach(\.chatTabGroup.tabInfo, action: \.chatTab) {
ChatTabItem()
}
}
diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/CircularWidgetFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/CircularWidget.swift
similarity index 91%
rename from Core/Sources/SuggestionWidget/FeatureReducers/CircularWidgetFeature.swift
rename to Core/Sources/SuggestionWidget/FeatureReducers/CircularWidget.swift
index 51b7d918..8b173c30 100644
--- a/Core/Sources/SuggestionWidget/FeatureReducers/CircularWidgetFeature.swift
+++ b/Core/Sources/SuggestionWidget/FeatureReducers/CircularWidget.swift
@@ -5,7 +5,7 @@ import SuggestionBasic
import SwiftUI
@Reducer
-public struct CircularWidgetFeature {
+public struct CircularWidget {
public struct IsProcessingCounter: Equatable {
var expirationDate: TimeInterval
}
@@ -24,6 +24,7 @@ public struct CircularWidgetFeature {
case widgetClicked
case detachChatPanelToggleClicked
case openChatButtonClicked
+ case openModificationButtonClicked
case runCustomCommandButtonClicked(CustomCommand)
case markIsProcessing
case endIsProcessing
@@ -45,6 +46,11 @@ public struct CircularWidgetFeature {
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/PromptToCode.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift
deleted file mode 100644
index 9ba5cad3..00000000
--- a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift
+++ /dev/null
@@ -1,276 +0,0 @@
-import AppKit
-import ComposableArchitecture
-import CustomAsyncAlgorithms
-import Dependencies
-import Foundation
-import PromptToCodeService
-import SuggestionBasic
-
-public struct PromptToCodeAcceptHandlerDependencyKey: DependencyKey {
- public static let liveValue: (PromptToCode.State) -> Void = { _ in
- assertionFailure("Please provide a handler")
- }
-
- public static let previewValue: (PromptToCode.State) -> Void = { _ in
- print("Accept Prompt to Code")
- }
-}
-
-public extension DependencyValues {
- var promptToCodeAcceptHandler: (PromptToCode.State) -> Void {
- get { self[PromptToCodeAcceptHandlerDependencyKey.self] }
- set { self[PromptToCodeAcceptHandlerDependencyKey.self] = newValue }
- }
-}
-
-@Reducer
-public struct PromptToCode {
- @ObservableState
- public struct State: Equatable, Identifiable {
- public indirect enum HistoryNode: Equatable {
- case empty
- case node(code: String, description: String, previous: HistoryNode)
-
- mutating func enqueue(code: String, description: String) {
- let current = self
- self = .node(code: code, description: description, previous: current)
- }
-
- mutating func pop() -> (code: String, description: String)? {
- switch self {
- case .empty:
- return nil
- case let .node(code, description, previous):
- self = previous
- return (code, description)
- }
- }
- }
-
- public enum FocusField: Equatable {
- case textField
- }
-
- public var id: URL { documentURL }
- public var history: HistoryNode
- public var code: String
- public var isResponding: Bool
- public var description: String
- public var error: String?
- public var selectionRange: CursorRange?
- public var language: CodeLanguage
- public var indentSize: Int
- public var usesTabsForIndentation: Bool
- public var projectRootURL: URL
- public var documentURL: URL
- public var allCode: String
- public var allLines: [String]
- public var extraSystemPrompt: String?
- public var generateDescriptionRequirement: Bool?
- public var commandName: String?
- public var prompt: String
- public var isContinuous: Bool
- public var isAttachedToSelectionRange: Bool
- public var focusedField: FocusField? = .textField
-
- public var filename: String { documentURL.lastPathComponent }
- public var canRevert: Bool { history != .empty }
-
- public init(
- code: String,
- prompt: String,
- language: CodeLanguage,
- indentSize: Int,
- usesTabsForIndentation: Bool,
- projectRootURL: URL,
- documentURL: URL,
- allCode: String,
- allLines: [String],
- commandName: String? = nil,
- description: String = "",
- isResponding: Bool = false,
- isAttachedToSelectionRange: Bool = true,
- error: String? = nil,
- history: HistoryNode = .empty,
- isContinuous: Bool = false,
- selectionRange: CursorRange? = nil,
- extraSystemPrompt: String? = nil,
- generateDescriptionRequirement: Bool? = nil
- ) {
- self.history = history
- self.code = code
- self.prompt = prompt
- self.isResponding = isResponding
- self.description = description
- self.error = error
- self.isContinuous = isContinuous
- self.selectionRange = selectionRange
- self.language = language
- self.indentSize = indentSize
- self.usesTabsForIndentation = usesTabsForIndentation
- self.projectRootURL = projectRootURL
- self.documentURL = documentURL
- self.allCode = allCode
- self.allLines = allLines
- self.extraSystemPrompt = extraSystemPrompt
- self.generateDescriptionRequirement = generateDescriptionRequirement
- self.isAttachedToSelectionRange = isAttachedToSelectionRange
- self.commandName = commandName
-
- if selectionRange?.isEmpty ?? true {
- self.isAttachedToSelectionRange = false
- }
- }
- }
-
- public enum Action: Equatable, BindableAction {
- case binding(BindingAction)
- case focusOnTextField
- case selectionRangeToggleTapped
- case modifyCodeButtonTapped
- case revertButtonTapped
- case stopRespondingButtonTapped
- case modifyCodeFinished
- case modifyCodeChunkReceived(code: String, description: String)
- case modifyCodeFailed(error: String)
- case modifyCodeCancelled
- case cancelButtonTapped
- case acceptButtonTapped
- case copyCodeButtonTapped
- case appendNewLineToPromptButtonTapped
- }
-
- @Dependency(\.promptToCodeService) var promptToCodeService
- @Dependency(\.promptToCodeAcceptHandler) var promptToCodeAcceptHandler
-
- enum CancellationKey: Hashable {
- case modifyCode(State.ID)
- }
-
- public var body: some ReducerOf {
- BindingReducer()
-
- Reduce { state, action in
- switch action {
- case .binding:
- return .none
-
- case .focusOnTextField:
- state.focusedField = .textField
- return .none
-
- case .selectionRangeToggleTapped:
- state.isAttachedToSelectionRange.toggle()
- return .none
-
- case .modifyCodeButtonTapped:
- guard !state.isResponding else { return .none }
- let copiedState = state
- state.history.enqueue(code: state.code, description: state.description)
- state.isResponding = true
- state.code = ""
- state.description = ""
- state.error = nil
-
- return .run { send in
- do {
- let stream = try await promptToCodeService.modifyCode(
- code: copiedState.code,
- requirement: copiedState.prompt,
- source: .init(
- language: copiedState.language,
- documentURL: copiedState.documentURL,
- projectRootURL: copiedState.projectRootURL,
- content: copiedState.allCode,
- lines: copiedState.allLines,
- range: copiedState.selectionRange ?? .outOfScope
- ),
- isDetached: !copiedState.isAttachedToSelectionRange,
- extraSystemPrompt: copiedState.extraSystemPrompt,
- generateDescriptionRequirement: copiedState
- .generateDescriptionRequirement
- ).timedDebounce(for: 0.2)
-
- for try await fragment in stream {
- try Task.checkCancellation()
- await send(.modifyCodeChunkReceived(
- code: fragment.code,
- description: fragment.description
- ))
- }
- try Task.checkCancellation()
- await send(.modifyCodeFinished)
- } catch is CancellationError {
- try Task.checkCancellation()
- await send(.modifyCodeCancelled)
- } catch {
- try Task.checkCancellation()
- if (error as NSError).code == NSURLErrorCancelled {
- await send(.modifyCodeCancelled)
- return
- }
-
- await send(.modifyCodeFailed(error: error.localizedDescription))
- }
- }.cancellable(id: CancellationKey.modifyCode(state.id), cancelInFlight: true)
-
- case .revertButtonTapped:
- guard let (code, description) = state.history.pop() else { return .none }
- state.code = code
- state.description = description
- return .none
-
- case .stopRespondingButtonTapped:
- state.isResponding = false
- promptToCodeService.stopResponding()
- return .cancel(id: CancellationKey.modifyCode(state.id))
-
- case let .modifyCodeChunkReceived(code, description):
- state.code = code
- state.description = description
- return .none
-
- case .modifyCodeFinished:
- state.prompt = ""
- state.isResponding = false
- if state.code.isEmpty, state.description.isEmpty {
- // if both code and description are empty, we treat it as failed
- return .run { send in
- await send(.revertButtonTapped)
- }
- }
-
- return .none
-
- case let .modifyCodeFailed(error):
- state.error = error
- state.isResponding = false
- return .run { send in
- await send(.revertButtonTapped)
- }
-
- case .modifyCodeCancelled:
- state.isResponding = false
- return .none
-
- case .cancelButtonTapped:
- promptToCodeService.stopResponding()
- return .none
-
- case .acceptButtonTapped:
- promptToCodeAcceptHandler(state)
- return .none
-
- case .copyCodeButtonTapped:
- NSPasteboard.general.clearContents()
- NSPasteboard.general.setString(state.code, forType: .string)
- return .none
-
- case .appendNewLineToPromptButtonTapped:
- state.prompt += "\n"
- return .none
- }
- }
- }
-}
-
diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift
index b9617798..d844b336 100644
--- a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift
+++ b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift
@@ -7,142 +7,105 @@ import XcodeInspector
@Reducer
public struct PromptToCodeGroup {
@ObservableState
- public struct State: Equatable {
- public var promptToCodes: IdentifiedArrayOf = []
- public var activeDocumentURL: PromptToCode.State.ID? = XcodeInspector.shared
+ public struct State {
+ public var promptToCodes: IdentifiedArrayOf = []
+ public var activeDocumentURL: PromptToCodePanel.State.ID? = XcodeInspector.shared
.realtimeActiveDocumentURL
- public var activePromptToCode: PromptToCode.State? {
+ public var selectedTabId: URL?
+ public var activePromptToCode: PromptToCodePanel.State? {
get {
- if let detached = promptToCodes.first(where: { !$0.isAttachedToSelectionRange }) {
- 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
}
}
}
}
- public struct PromptToCodeInitialState: Equatable {
- public var code: String
- public var selectionRange: CursorRange?
- public var language: CodeLanguage
- public var identSize: Int
- public var usesTabsForIndentation: Bool
- public var documentURL: URL
- public var projectRootURL: URL
- public var allCode: String
- public var allLines: [String]
- public var isContinuous: Bool
- public var commandName: String?
- public var defaultPrompt: String
- public var extraSystemPrompt: String?
- public var generateDescriptionRequirement: Bool?
-
- public init(
- code: String,
- selectionRange: CursorRange?,
- language: CodeLanguage,
- identSize: Int,
- usesTabsForIndentation: Bool,
- documentURL: URL,
- projectRootURL: URL,
- allCode: String,
- allLines: [String],
- isContinuous: Bool,
- commandName: String?,
- defaultPrompt: String,
- extraSystemPrompt: String?,
- generateDescriptionRequirement: Bool?
- ) {
- self.code = code
- self.selectionRange = selectionRange
- self.language = language
- self.identSize = identSize
- self.usesTabsForIndentation = usesTabsForIndentation
- self.documentURL = documentURL
- self.projectRootURL = projectRootURL
- self.allCode = allCode
- self.allLines = allLines
- self.isContinuous = isContinuous
- self.commandName = commandName
- self.defaultPrompt = defaultPrompt
- self.extraSystemPrompt = extraSystemPrompt
- self.generateDescriptionRequirement = generateDescriptionRequirement
- }
- }
-
- public enum Action: Equatable {
+ public enum Action {
/// Activate the prompt to code if it exists or create it if it doesn't
- case activateOrCreatePromptToCode(PromptToCodeInitialState)
- case createPromptToCode(PromptToCodeInitialState)
- case updatePromptToCodeRange(id: PromptToCode.State.ID, range: CursorRange)
- case discardAcceptedPromptToCodeIfNotContinuous(id: PromptToCode.State.ID)
+ case activateOrCreatePromptToCode(PromptToCodePanel.State)
+ case createPromptToCode(PromptToCodePanel.State, sendImmediately: Bool)
+ case updatePromptToCodeRange(
+ id: PromptToCodePanel.State.ID,
+ snippetId: UUID,
+ range: CursorRange
+ )
+ case discardAcceptedPromptToCodeIfNotContinuous(id: PromptToCodePanel.State.ID)
case updateActivePromptToCode(documentURL: URL)
case discardExpiredPromptToCode(documentURLs: [URL])
- case promptToCode(PromptToCode.State.ID, PromptToCode.Action)
- case activePromptToCode(PromptToCode.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(s):
- let newPromptToCode = PromptToCode.State(
- code: s.code,
- prompt: s.defaultPrompt,
- language: s.language,
- indentSize: s.identSize,
- usesTabsForIndentation: s.usesTabsForIndentation,
- projectRootURL: s.projectRootURL,
- documentURL: s.documentURL,
- allCode: s.allCode,
- allLines: s.allLines,
- commandName: s.commandName,
- isContinuous: s.isContinuous,
- selectionRange: s.selectionRange,
- extraSystemPrompt: s.extraSystemPrompt,
- generateDescriptionRequirement: s.generateDescriptionRequirement
- )
- // 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.prompt.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: PromptToCode.CancellationKey.modifyCode(newPromptToCode.id),
+ id: PromptToCodePanel.CancellationKey.modifyCode(newPromptToCode.id),
cancelInFlight: true
)
- case let .updatePromptToCodeRange(id, range):
- if let p = state.promptToCodes[id: id], p.isAttachedToSelectionRange {
- state.promptToCodes[id: id]?.selectionRange = range
+ case let .updatePromptToCodeRange(id, snippetId, range):
+ if let p = state.promptToCodes[id: id], p.promptToCodeState.isAttachedToTarget {
+ state.promptToCodes[id: id]?.promptToCodeState.snippets[id: snippetId]?
+ .attachedRange = range
}
return .none
case let .discardAcceptedPromptToCodeIfNotContinuous(id):
- state.promptToCodes.removeAll { $0.id == id && !$0.isContinuous }
+ 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):
@@ -151,34 +114,69 @@ 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
-
+
case .activePromptToCode:
return .none
}
}
.ifLet(\.activePromptToCode, action: \.activePromptToCode) {
- PromptToCode()
- .dependency(\.promptToCodeService, promptToCodeServiceFactory())
+ PromptToCodePanel()
}
- .forEach(\.promptToCodes, action: /Action.promptToCode, element: {
- PromptToCode()
- .dependency(\.promptToCodeService, promptToCodeServiceFactory())
+ .forEach(\.promptToCodes, action: \.promptToCode, element: {
+ PromptToCodePanel()
})
-
+
Reduce { state, action in
switch action {
- case let .promptToCode(id, .cancelButtonTapped):
+ case let .promptToCode(.element(id, .cancelButtonTapped)):
state.promptToCodes.remove(id: id)
+ let isEmpty = state.promptToCodes.isEmpty
return .run { _ in
- activatePreviousActiveXcode()
+ if isEmpty {
+ activatePreviousActiveXcode()
+ }
}
case .activePromptToCode(.cancelButtonTapped):
- guard let id = state.activePromptToCode?.id else { return .none }
+ guard let id = state.selectedTabId else { return .none }
state.promptToCodes.remove(id: id)
+ let isEmpty = state.promptToCodes.isEmpty
return .run { _ in
- activatePreviousActiveXcode()
+ if isEmpty {
+ activatePreviousActiveXcode()
+ }
}
default: return .none
}
diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift
new file mode 100644
index 00000000..cb68435f
--- /dev/null
+++ b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift
@@ -0,0 +1,364 @@
+import AppKit
+import ChatBasic
+import ComposableArchitecture
+import CustomAsyncAlgorithms
+import Dependencies
+import Foundation
+import ModificationBasic
+import Preferences
+import PromptToCodeCustomization
+import PromptToCodeService
+import SuggestionBasic
+import XcodeInspector
+
+@Reducer
+public struct PromptToCodePanel {
+ @ObservableState
+ public struct State: Identifiable {
+ public enum FocusField: Equatable {
+ case textField
+ }
+
+ 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 commandName: String?
+ public var isContinuous: Bool
+ public var focusedField: FocusField? = .textField
+
+ public var filename: String {
+ promptToCodeState.source.documentURL.lastPathComponent
+ }
+
+ public var canRevert: Bool { !promptToCodeState.history.isEmpty }
+
+ public var generateDescriptionRequirement: Bool
+
+ public var clickedButton: ClickedButton?
+
+ public var isActiveDocument: Bool = false
+
+ public var snippetPanels: IdentifiedArrayOf {
+ get {
+ IdentifiedArrayOf(
+ uniqueElements: promptToCodeState.snippets.map {
+ PromptToCodeSnippetPanel.State(snippet: $0)
+ }
+ )
+ }
+ set {
+ promptToCodeState.snippets = IdentifiedArrayOf(
+ uniqueElements: newValue.map(\.snippet)
+ )
+ }
+ }
+
+ public init(
+ promptToCodeState: Shared,
+ instruction: String?,
+ commandName: String? = nil,
+ isContinuous: Bool = false,
+ generateDescriptionRequirement: Bool = UserDefaults.shared
+ .value(for: \.promptToCodeGenerateDescription)
+ ) {
+ _promptToCodeState = promptToCodeState
+ self.isContinuous = isContinuous
+ self.generateDescriptionRequirement = generateDescriptionRequirement
+ self.commandName = commandName
+ contextInputController = PromptToCodeCustomization
+ .contextInputControllerFactory(promptToCodeState)
+ focusedField = .textField
+ contextInputController.instruction = instruction
+ .map(NSAttributedString.init(string:)) ?? .init()
+ }
+ }
+
+ public enum Action: BindableAction {
+ case binding(BindingAction)
+ case focusOnTextField
+ case selectionRangeToggleTapped
+ case modifyCodeButtonTapped
+ case revertButtonTapped
+ case stopRespondingButtonTapped
+ case modifyCodeFinished
+ case modifyCodeCancelled
+ case cancelButtonTapped
+ case acceptButtonTapped
+ case acceptAndContinueButtonTapped
+ case revealFileButtonClicked
+ case statusUpdated([String])
+ case referencesUpdated([ChatMessage.Reference])
+ case snippetPanel(IdentifiedActionOf)
+ }
+
+ @Dependency(\.commandHandler) var commandHandler
+ @Dependency(\.activateThisApp) var activateThisApp
+ @Dependency(\.activatePreviousActiveXcode) var activatePreviousActiveXcode
+
+ enum CancellationKey: Hashable {
+ case modifyCode(State.ID)
+ }
+
+ public var body: some ReducerOf {
+ BindingReducer()
+
+ Reduce { state, action in
+ switch action {
+ case .binding:
+ return .none
+
+ case .snippetPanel:
+ return .none
+
+ case .focusOnTextField:
+ state.focusedField = .textField
+ return .none
+
+ case .selectionRangeToggleTapped:
+ state.promptToCodeState.isAttachedToTarget.toggle()
+ return .none
+
+ case .modifyCodeButtonTapped:
+ guard !state.promptToCodeState.isGenerating else { return .none }
+ let copiedState = state
+ let contextInputController = state.contextInputController
+ state.promptToCodeState.isGenerating = true
+ 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 (index, snippet) in snippets.enumerated() {
+ if index > 3 { // at most 3 at a time
+ _ = try await group.next()
+ }
+ group.addTask {
+ 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: context.instruction,
+ source: .init(
+ language: copiedState.promptToCodeState.source.language,
+ documentURL: copiedState.promptToCodeState.source
+ .documentURL,
+ projectRootURL: copiedState.promptToCodeState.source
+ .projectRootURL,
+ content: copiedState.promptToCodeState.source.content,
+ lines: copiedState.promptToCodeState.source.lines
+ ),
+ isDetached: !copiedState.promptToCodeState
+ .isAttachedToTarget,
+ extraSystemPrompt: copiedState.promptToCodeState
+ .extraSystemPrompt,
+ 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 response in stream {
+ try Task.checkCancellation()
+ await send(.snippetPanel(.element(
+ id: snippet.id,
+ action: .modifyCodeChunkReceived(
+ code: response.code,
+ description: response.description
+ )
+ )))
+ }
+ await send(.snippetPanel(.element(
+ id: snippet.id,
+ action: .modifyCodeFinished
+ )))
+ } catch is CancellationError {
+ await send(.snippetPanel(.element(
+ id: snippet.id,
+ action: .modifyCodeFinished
+ )))
+ throw CancellationError()
+ } catch {
+ if (error as NSError).code == NSURLErrorCancelled {
+ await send(.snippetPanel(.element(
+ id: snippet.id,
+ action: .modifyCodeFinished
+ )))
+ throw CancellationError()
+ }
+ await send(.snippetPanel(.element(
+ id: snippet.id,
+ action: .modifyCodeFailed(
+ error: error.localizedDescription
+ )
+ )))
+ }
+ }
+ }
+
+ try await group.waitForAll()
+ }
+
+ await send(.modifyCodeFinished)
+ } catch is CancellationError {
+ try Task.checkCancellation()
+ await send(.modifyCodeCancelled)
+ } catch {
+ await send(.modifyCodeFinished)
+ }
+ }.cancellable(id: CancellationKey.modifyCode(state.id), cancelInFlight: true)
+
+ case .revertButtonTapped:
+ if let instruction = state.promptToCodeState.popHistory() {
+ state.contextInputController.instruction = instruction
+ }
+ return .none
+
+ case .stopRespondingButtonTapped:
+ state.promptToCodeState.isGenerating = false
+ state.promptToCodeState.status = []
+ return .cancel(id: CancellationKey.modifyCode(state.id))
+
+ case .modifyCodeFinished:
+ 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
+ .error == nil
+ }) {
+ // if both code and description are empty, we treat it as failed
+ return .run { send in
+ await send(.revertButtonTapped)
+ }
+ }
+ return .none
+
+ case .modifyCodeCancelled:
+ state.promptToCodeState.isGenerating = false
+ return .none
+
+ case .cancelButtonTapped:
+ return .cancel(id: CancellationKey.modifyCode(state.id))
+
+ case .acceptButtonTapped:
+ state.clickedButton = .accept
+ return .run { _ in
+ await commandHandler.acceptModification()
+ activatePreviousActiveXcode()
+ }
+
+ case .acceptAndContinueButtonTapped:
+ state.clickedButton = .acceptAndContinue
+ return .run { _ in
+ await commandHandler.acceptModification()
+ activateThisApp()
+ }
+
+ case .revealFileButtonClicked:
+ let url = state.promptToCodeState.source.documentURL
+ let startLine = state.snippetPanels.first?.snippet.attachedRange.start.line ?? 0
+ return .run { _ in
+ await commandHandler.presentFile(at: url, line: startLine)
+ }
+
+ case let .statusUpdated(status):
+ state.promptToCodeState.status = status
+ return .none
+
+ case let .referencesUpdated(references):
+ state.promptToCodeState.references = references
+ return .none
+ }
+ }
+
+ Reduce { _, _ in .none }.forEach(\.snippetPanels, action: \.snippetPanel) {
+ PromptToCodeSnippetPanel()
+ }
+ }
+}
+
+@Reducer
+public struct PromptToCodeSnippetPanel {
+ @ObservableState
+ public struct State: Identifiable {
+ public var id: UUID { snippet.id }
+ var snippet: ModificationSnippet
+ }
+
+ public enum Action {
+ case modifyCodeFinished
+ case modifyCodeChunkReceived(code: String, description: String)
+ case modifyCodeFailed(error: String)
+ case copyCodeButtonTapped
+ }
+
+ public var body: some ReducerOf {
+ Reduce { state, action in
+ switch action {
+ case .modifyCodeFinished:
+ return .none
+
+ case let .modifyCodeChunkReceived(code, description):
+ state.snippet.modifiedCode += code
+ state.snippet.description += description
+ return .none
+
+ case let .modifyCodeFailed(error):
+ state.snippet.error = error
+ return .none
+
+ case .copyCodeButtonTapped:
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(state.snippet.modifiedCode, forType: .string)
+ return .none
+ }
+ }
+ }
+}
+
+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/SharedPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanel.swift
similarity index 79%
rename from Core/Sources/SuggestionWidget/FeatureReducers/SharedPanelFeature.swift
rename to Core/Sources/SuggestionWidget/FeatureReducers/SharedPanel.swift
index 232f29f4..9f38210e 100644
--- a/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanelFeature.swift
+++ b/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanel.swift
@@ -3,23 +3,22 @@ import Preferences
import SwiftUI
@Reducer
-public struct SharedPanelFeature {
- public struct Content: Equatable {
+public struct SharedPanel {
+ public struct Content {
public var promptToCodeGroup = PromptToCodeGroup.State()
- var suggestion: CodeSuggestionProvider?
- public var promptToCode: PromptToCode.State? { promptToCodeGroup.activePromptToCode }
+ var suggestion: PresentingCodeSuggestion?
var error: String?
}
@ObservableState
- public struct State: Equatable {
+ public struct State {
var content: Content = .init()
var colorScheme: ColorScheme = .light
var alignTopToAnchor = false
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 }
@@ -33,7 +32,7 @@ public struct SharedPanelFeature {
}
}
- public enum Action: Equatable {
+ public enum Action {
case errorMessageCloseButtonTapped
case promptToCodeGroup(PromptToCodeGroup.Action)
}
diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanel.swift
similarity index 88%
rename from Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanelFeature.swift
rename to Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanel.swift
index 00805391..7baef1df 100644
--- a/Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanelFeature.swift
+++ b/Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanel.swift
@@ -3,10 +3,10 @@ import Foundation
import SwiftUI
@Reducer
-public struct SuggestionPanelFeature {
+public struct SuggestionPanel {
@ObservableState
public struct State: Equatable {
- var content: CodeSuggestionProvider?
+ var content: PresentingCodeSuggestion?
var colorScheme: ColorScheme = .light
var alignTopToAnchor = false
var isPanelDisplayed: Bool = false
diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/Widget.swift
similarity index 91%
rename from Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift
rename to Core/Sources/SuggestionWidget/FeatureReducers/Widget.swift
index 68ecc382..493628fc 100644
--- a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift
+++ b/Core/Sources/SuggestionWidget/FeatureReducers/Widget.swift
@@ -10,7 +10,7 @@ import Toast
import XcodeInspector
@Reducer
-public struct WidgetFeature {
+public struct Widget {
public struct WindowState: Equatable {
var alphaValue: Double = 0
var frame: CGRect = .zero
@@ -22,7 +22,7 @@ public struct WidgetFeature {
}
@ObservableState
- public struct State: Equatable {
+ public struct State {
var focusingDocumentURL: URL?
public var colorScheme: ColorScheme = .light
@@ -30,21 +30,21 @@ public struct WidgetFeature {
// MARK: Panels
- public var panelState = PanelFeature.State()
+ public var panelState = WidgetPanel.State()
// MARK: ChatPanel
- public var chatPanelState = ChatPanelFeature.State()
+ public var chatPanelState = ChatPanel.State()
// MARK: CircularWidget
public struct CircularWidgetState: Equatable {
- var isProcessingCounters = [CircularWidgetFeature.IsProcessingCounter]()
+ var isProcessingCounters = [CircularWidget.IsProcessingCounter]()
var isProcessing: Bool = false
}
public var circularWidgetState = CircularWidgetState()
- var _internalCircularWidgetState: CircularWidgetFeature.State {
+ var _internalCircularWidgetState: CircularWidget.State {
get {
.init(
isProcessingCounters: circularWidgetState.isProcessingCounters,
@@ -90,7 +90,7 @@ public struct WidgetFeature {
case observeUserDefaults
}
- public enum Action: Equatable {
+ public enum Action {
case startup
case observeActiveApplicationChange
case observeColorSchemeChange
@@ -104,9 +104,9 @@ public struct WidgetFeature {
case updateKeyWindow(WindowCanBecomeKey)
case toastPanel(ToastPanel.Action)
- case panel(PanelFeature.Action)
- case chatPanel(ChatPanelFeature.Action)
- case circularWidget(CircularWidgetFeature.Action)
+ case panel(WidgetPanel.Action)
+ case chatPanel(ChatPanel.Action)
+ case circularWidget(CircularWidget.Action)
}
var windowsController: WidgetWindowsController? {
@@ -132,7 +132,7 @@ public struct WidgetFeature {
}
Scope(state: \._internalCircularWidgetState, action: \.circularWidget) {
- CircularWidgetFeature()
+ CircularWidget()
}
Reduce { state, action in
@@ -181,11 +181,11 @@ public struct WidgetFeature {
}
Scope(state: \.panelState, action: \.panel) {
- PanelFeature()
+ WidgetPanel()
}
Scope(state: \.chatPanelState, action: \.chatPanel) {
- ChatPanelFeature()
+ ChatPanel()
}
Reduce { state, action in
@@ -232,12 +232,18 @@ public struct WidgetFeature {
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 WidgetFeature {
case .updateFocusingDocumentURL:
return .run { send in
await send(.setFocusingDocumentURL(
- to: await xcodeInspector.safe
- .realtimeActiveDocumentURL
+ to: xcodeInspector.realtimeActiveDocumentURL
))
}
diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetPanel.swift
similarity index 80%
rename from Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift
rename to Core/Sources/SuggestionWidget/FeatureReducers/WidgetPanel.swift
index 0467da4b..7d911f75 100644
--- a/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift
+++ b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetPanel.swift
@@ -3,10 +3,10 @@ import ComposableArchitecture
import Foundation
@Reducer
-public struct PanelFeature {
+public struct WidgetPanel {
@ObservableState
- public struct State: Equatable {
- public var content: SharedPanelFeature.Content {
+ public struct State {
+ public var content: SharedPanel.Content {
get { sharedPanelState.content }
set {
sharedPanelState.content = newValue
@@ -16,25 +16,25 @@ public struct PanelFeature {
// MARK: SharedPanel
- var sharedPanelState = SharedPanelFeature.State()
+ var sharedPanelState = SharedPanel.State()
// MARK: SuggestionPanel
- var suggestionPanelState = SuggestionPanelFeature.State()
+ var suggestionPanelState = SuggestionPanel.State()
}
- public enum Action: Equatable {
+ public enum Action {
case presentSuggestion
- case presentSuggestionProvider(CodeSuggestionProvider, displayContent: Bool)
+ case presentSuggestionProvider(PresentingCodeSuggestion, displayContent: Bool)
case presentError(String)
- case presentPromptToCode(PromptToCodeGroup.PromptToCodeInitialState)
+ case presentPromptToCode(PromptToCodePanel.State)
case displayPanelContent
case discardSuggestion
case removeDisplayedContent
case switchToAnotherEditorAndUpdateContent
- case sharedPanel(SharedPanelFeature.Action)
- case suggestionPanel(SuggestionPanelFeature.Action)
+ case sharedPanel(SharedPanel.Action)
+ case suggestionPanel(SuggestionPanel.Action)
}
@Dependency(\.suggestionWidgetControllerDependency) var suggestionWidgetControllerDependency
@@ -44,18 +44,18 @@ public struct PanelFeature {
public var body: some ReducerOf {
Scope(state: \.suggestionPanelState, action: \.suggestionPanel) {
- SuggestionPanelFeature()
+ SuggestionPanel()
}
Scope(state: \.sharedPanelState, action: \.sharedPanel) {
- SharedPanelFeature()
+ SharedPanel()
}
Reduce { state, action in
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 PanelFeature {
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 PanelFeature {
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 PanelFeature {
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)
@@ -136,7 +139,7 @@ public struct PanelFeature {
}
}
- func fetchSuggestionProvider(fileURL: URL) async -> CodeSuggestionProvider? {
+ func fetchSuggestionProvider(fileURL: URL) async -> PresentingCodeSuggestion? {
guard let provider = await suggestionWidgetControllerDependency
.suggestionWidgetDataSource?
.suggestionForFile(at: fileURL) else { return nil }
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/Providers/CodeSuggestionProvider.swift b/Core/Sources/SuggestionWidget/Providers/CodeSuggestionProvider.swift
deleted file mode 100644
index dd50233f..00000000
--- a/Core/Sources/SuggestionWidget/Providers/CodeSuggestionProvider.swift
+++ /dev/null
@@ -1,60 +0,0 @@
-import Combine
-import Foundation
-import Perception
-import SharedUIComponents
-import SwiftUI
-import XcodeInspector
-
-@Perceptible
-public final class CodeSuggestionProvider: Equatable {
- public static func == (lhs: CodeSuggestionProvider, rhs: CodeSuggestionProvider) -> Bool {
- lhs.code == rhs.code && lhs.language == rhs.language
- }
-
- public var code: String = ""
- public var language: String = ""
- public var startLineIndex: Int = 0
- public var suggestionCount: Int = 0
- public var currentSuggestionIndex: Int = 0
- public var extraInformation: String = ""
-
- @PerceptionIgnored public var onSelectPreviousSuggestionTapped: () -> Void
- @PerceptionIgnored public var onSelectNextSuggestionTapped: () -> Void
- @PerceptionIgnored public var onRejectSuggestionTapped: () -> Void
- @PerceptionIgnored public var onAcceptSuggestionTapped: () -> Void
- @PerceptionIgnored public var onDismissSuggestionTapped: () -> Void
-
- public init(
- code: String = "",
- language: String = "",
- startLineIndex: Int = 0,
- startCharacerIndex: Int = 0,
- suggestionCount: Int = 0,
- currentSuggestionIndex: Int = 0,
- onSelectPreviousSuggestionTapped: @escaping () -> Void = {},
- onSelectNextSuggestionTapped: @escaping () -> Void = {},
- onRejectSuggestionTapped: @escaping () -> Void = {},
- onAcceptSuggestionTapped: @escaping () -> Void = {},
- onDismissSuggestionTapped: @escaping () -> Void = {}
- ) {
- self.code = code
- self.language = language
- self.startLineIndex = startLineIndex
- self.suggestionCount = suggestionCount
- self.currentSuggestionIndex = currentSuggestionIndex
- self.onSelectPreviousSuggestionTapped = onSelectPreviousSuggestionTapped
- self.onSelectNextSuggestionTapped = onSelectNextSuggestionTapped
- self.onRejectSuggestionTapped = onRejectSuggestionTapped
- self.onAcceptSuggestionTapped = onAcceptSuggestionTapped
- self.onDismissSuggestionTapped = onDismissSuggestionTapped
- }
-
- func selectPreviousSuggestion() { onSelectPreviousSuggestionTapped() }
- func selectNextSuggestion() { onSelectNextSuggestionTapped() }
- func rejectSuggestion() { onRejectSuggestionTapped() }
- func acceptSuggestion() { onAcceptSuggestionTapped() }
- func dismissSuggestion() { onDismissSuggestionTapped() }
-
-
-}
-
diff --git a/Core/Sources/SuggestionWidget/SharedPanelView.swift b/Core/Sources/SuggestionWidget/SharedPanelView.swift
index 697a0663..a00b2fee 100644
--- a/Core/Sources/SuggestionWidget/SharedPanelView.swift
+++ b/Core/Sources/SuggestionWidget/SharedPanelView.swift
@@ -19,7 +19,7 @@ extension View {
}
struct SharedPanelView: View {
- var store: StoreOf
+ var store: StoreOf
struct OverallState: Equatable {
var isPanelDisplayed: Bool
@@ -29,40 +29,41 @@ struct SharedPanelView: View {
}
var body: some View {
- WithPerceptionTracking {
- VStack(spacing: 0) {
- if !store.alignTopToAnchor {
- Spacer()
- .frame(minHeight: 0, maxHeight: .infinity)
- .allowsHitTesting(false)
- }
-
- DynamicContent(store: store)
+ GeometryReader { geometry in
+ WithPerceptionTracking {
+ VStack(spacing: 0) {
+ if !store.alignTopToAnchor {
+ Spacer()
+ .frame(minHeight: 0, maxHeight: .infinity)
+ .allowsHitTesting(false)
+ }
- .frame(maxWidth: .infinity, maxHeight: Style.panelHeight)
- .fixedSize(horizontal: false, vertical: true)
- .allowsHitTesting(store.isPanelDisplayed)
- .frame(maxWidth: .infinity)
+ DynamicContent(store: store)
+ .frame(maxWidth: .infinity, maxHeight: geometry.size.height)
+ .fixedSize(horizontal: false, vertical: true)
+ .allowsHitTesting(store.isPanelDisplayed)
+ .layoutPriority(1)
- if store.alignTopToAnchor {
- Spacer()
- .frame(minHeight: 0, maxHeight: .infinity)
- .allowsHitTesting(false)
+ if store.alignTopToAnchor {
+ Spacer()
+ .frame(minHeight: 0, maxHeight: .infinity)
+ .allowsHitTesting(false)
+ }
}
+ .preferredColorScheme(store.colorScheme)
+ .opacity(store.opacity)
+ .animation(
+ featureFlag: \.animationBCrashSuggestion,
+ .easeInOut(duration: 0.2),
+ value: store.isPanelDisplayed
+ )
+ .frame(maxWidth: Style.panelWidth, maxHeight: .infinity)
}
- .preferredColorScheme(store.colorScheme)
- .opacity(store.opacity)
- .animation(
- featureFlag: \.animationBCrashSuggestion,
- .easeInOut(duration: 0.2),
- value: store.isPanelDisplayed
- )
- .frame(maxWidth: Style.panelWidth, maxHeight: Style.panelHeight)
}
}
struct DynamicContent: View {
- let store: StoreOf
+ let store: StoreOf
@AppStorage(\.suggestionPresentationMode) var suggestionPresentationMode
@@ -71,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)
@@ -82,7 +83,7 @@ struct SharedPanelView: View {
@ViewBuilder
func error(_ error: String) -> some View {
- ErrorPanel(description: error) {
+ ErrorPanelView(description: error) {
store.send(
.errorMessageCloseButtonTapped,
animation: .easeInOut(duration: 0.2)
@@ -92,21 +93,19 @@ struct SharedPanelView: View {
@ViewBuilder
func promptToCode() -> some View {
- if let store = store.scope(
- state: \.content.promptToCodeGroup.activePromptToCode,
- action: \.promptToCodeGroup.activePromptToCode
- ) {
- PromptToCodePanel(store: store)
- }
+ PromptToCodePanelGroupView(store: store.scope(
+ state: \.content.promptToCodeGroup,
+ action: \.promptToCodeGroup
+ ))
}
@ViewBuilder
- func suggestion(_ suggestion: CodeSuggestionProvider) -> some View {
+ func suggestion(_ suggestion: PresentingCodeSuggestion) -> some View {
switch suggestionPresentationMode {
case .nearbyTextCursor:
EmptyView()
case .floatingWidget:
- CodeBlockSuggestionPanel(suggestion: suggestion)
+ CodeBlockSuggestionPanelView(suggestion: suggestion)
}
}
}
@@ -143,7 +142,7 @@ struct SharedPanelView_Error_Preview: PreviewProvider {
colorScheme: .light,
isPanelDisplayed: true
),
- reducer: { SharedPanelFeature() }
+ reducer: { SharedPanel() }
))
.frame(width: 450, height: 200)
}
@@ -163,13 +162,15 @@ struct SharedPanelView_Both_DisplayingSuggestion_Preview: PreviewProvider {
language: "objective-c",
startLineIndex: 8,
suggestionCount: 2,
- currentSuggestionIndex: 0
+ currentSuggestionIndex: 0,
+ replacingRange: .zero,
+ replacingLines: [""]
)
),
colorScheme: .dark,
isPanelDisplayed: true
),
- reducer: { SharedPanelFeature() }
+ reducer: { SharedPanel() }
))
.frame(width: 450, height: 200)
.background {
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/CodeBlockSuggestionPanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift
deleted file mode 100644
index a0125683..00000000
--- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift
+++ /dev/null
@@ -1,249 +0,0 @@
-import Combine
-import Perception
-import SharedUIComponents
-import SuggestionBasic
-import SwiftUI
-import XcodeInspector
-
-struct CodeBlockSuggestionPanel: View {
- let suggestion: CodeSuggestionProvider
- @Environment(CursorPositionTracker.self) var cursorPositionTracker
- @Environment(\.colorScheme) var colorScheme
- @AppStorage(\.suggestionCodeFont) var codeFont
- @AppStorage(\.suggestionDisplayCompactMode) var suggestionDisplayCompactMode
- @AppStorage(\.suggestionPresentationMode) var suggestionPresentationMode
- @AppStorage(\.hideCommonPrecedingSpacesInSuggestion) var hideCommonPrecedingSpaces
- @AppStorage(\.syncSuggestionHighlightTheme) var syncHighlightTheme
- @AppStorage(\.codeForegroundColorLight) var codeForegroundColorLight
- @AppStorage(\.codeForegroundColorDark) var codeForegroundColorDark
- @AppStorage(\.codeBackgroundColorLight) var codeBackgroundColorLight
- @AppStorage(\.codeBackgroundColorDark) var codeBackgroundColorDark
-
- struct ToolBar: View {
- let suggestion: CodeSuggestionProvider
-
- var body: some View {
- WithPerceptionTracking {
- HStack {
- Button(action: {
- suggestion.selectPreviousSuggestion()
- }) {
- Image(systemName: "chevron.left")
- }.buttonStyle(.plain)
-
- Text(
- "\(suggestion.currentSuggestionIndex + 1) / \(suggestion.suggestionCount)"
- )
- .monospacedDigit()
-
- Button(action: {
- suggestion.selectNextSuggestion()
- }) {
- Image(systemName: "chevron.right")
- }.buttonStyle(.plain)
-
- Spacer()
-
- Button(action: {
- suggestion.dismissSuggestion()
- }) {
- Text("Dismiss").foregroundStyle(.tertiary).padding(.trailing, 4)
- }.buttonStyle(.plain)
-
- Button(action: {
- suggestion.rejectSuggestion()
- }) {
- Text("Reject")
- }.buttonStyle(CommandButtonStyle(color: .gray))
-
- Button(action: {
- suggestion.acceptSuggestion()
- }) {
- Text("Accept")
- }.buttonStyle(CommandButtonStyle(color: .accentColor))
- }
- .padding()
- .foregroundColor(.secondary)
- .background(.regularMaterial)
- }
- }
- }
-
- struct CompactToolBar: View {
- let suggestion: CodeSuggestionProvider
-
- var body: some View {
- WithPerceptionTracking {
- HStack {
- Button(action: {
- suggestion.selectPreviousSuggestion()
- }) {
- Image(systemName: "chevron.left")
- }.buttonStyle(.plain)
-
- Text(
- "\(suggestion.currentSuggestionIndex + 1) / \(suggestion.suggestionCount)"
- )
- .monospacedDigit()
-
- Button(action: {
- suggestion.selectNextSuggestion()
- }) {
- Image(systemName: "chevron.right")
- }.buttonStyle(.plain)
-
- Spacer()
-
- Button(action: {
- suggestion.dismissSuggestion()
- }) {
- Image(systemName: "xmark")
- }.buttonStyle(.plain)
- }
- .padding(4)
- .font(.caption)
- .foregroundColor(.secondary)
- .background(.regularMaterial)
- }
- }
- }
-
- var body: some View {
- WithPerceptionTracking {
- VStack(spacing: 0) {
- CustomScrollView {
- WithPerceptionTracking {
- AsyncCodeBlock(
- code: suggestion.code,
- language: suggestion.language,
- startLineIndex: suggestion.startLineIndex,
- scenario: "suggestion",
- font: codeFont.value.nsFont,
- droppingLeadingSpaces: hideCommonPrecedingSpaces,
- proposedForegroundColor: {
- if syncHighlightTheme {
- if colorScheme == .light,
- let color = codeForegroundColorLight.value?.swiftUIColor
- {
- return color
- } else if let color = codeForegroundColorDark.value?
- .swiftUIColor
- {
- return color
- }
- }
- return nil
- }(),
- dimmedCharacterCount: suggestion.startLineIndex
- == cursorPositionTracker.cursorPosition.line
- ? cursorPositionTracker.cursorPosition.character
- : 0
- )
- .frame(maxWidth: .infinity)
- .background({ () -> Color in
- if syncHighlightTheme {
- if colorScheme == .light,
- let color = codeBackgroundColorLight.value?.swiftUIColor
- {
- return color
- } else if let color = codeBackgroundColorDark.value?.swiftUIColor {
- return color
- }
- }
- return Color.contentBackground
- }())
- }
- }
-
- if suggestionDisplayCompactMode {
- CompactToolBar(suggestion: suggestion)
- } else {
- ToolBar(suggestion: suggestion)
- }
- }
- .xcodeStyleFrame(cornerRadius: {
- switch suggestionPresentationMode {
- case .nearbyTextCursor: 6
- case .floatingWidget: nil
- }
- }())
- }
- }
-}
-
-// MARK: - Previews
-
-#Preview("Code Block Suggestion Panel") {
- CodeBlockSuggestionPanel(suggestion: CodeSuggestionProvider(
- code: """
- LazyVGrid(columns: [GridItem(.fixed(30)), GridItem(.flexible())]) {
- ForEach(0.. Color in
+ if syncHighlightTheme {
+ if colorScheme == .light,
+ let color = codeBackgroundColorLight.value?.swiftUIColor
+ {
+ return color
+ } else if let color = codeBackgroundColorDark.value?.swiftUIColor {
+ return color
+ }
+ }
+ return Color.contentBackground
+ }())
+ }
+ }
+
+ Description(descriptions: suggestion.descriptions)
+
+ Divider()
+
+ if suggestionDisplayCompactMode {
+ CompactToolBar(suggestion: suggestion)
+ } else {
+ ToolBar(suggestion: suggestion)
+ }
+ }
+ .xcodeStyleFrame(cornerRadius: {
+ switch suggestionPresentationMode {
+ case .nearbyTextCursor:
+ if #available(macOS 26.0, *) {
+ return 8
+ } else {
+ return 6
+ }
+ case .floatingWidget: return nil
+ }
+ }())
+ }
+ }
+
+ @MainActor
+ func extractCode() -> (
+ code: String,
+ originalCode: String,
+ dimmedCharacterCount: AsyncCodeBlock.DimmedCharacterCount
+ ) {
+ var range = suggestion.replacingRange
+ range.end = .init(line: range.end.line - range.start.line, character: range.end.character)
+ range.start = .init(line: 0, character: range.start.character)
+ let codeInRange = EditorInformation.code(in: suggestion.replacingLines, inside: range)
+ let leftover = {
+ if range.end.line >= 0, range.end.line < suggestion.replacingLines.endIndex {
+ let lastLine = suggestion.replacingLines[range.end.line]
+ if range.end.character < lastLine.utf16.count {
+ let startIndex = lastLine.utf16.index(
+ lastLine.utf16.startIndex,
+ offsetBy: range.end.character
+ )
+ var leftover = String(lastLine.utf16.suffix(from: startIndex))
+ if leftover?.last?.isNewline ?? false {
+ leftover?.removeLast(1)
+ }
+ return leftover ?? ""
+ }
+ }
+ return ""
+ }()
+
+ let prefix = {
+ if range.start.line >= 0, range.start.line < suggestion.replacingLines.endIndex {
+ let firstLine = suggestion.replacingLines[range.start.line]
+ if range.start.character < firstLine.utf16.count {
+ let endIndex = firstLine.utf16.index(
+ firstLine.utf16.startIndex,
+ offsetBy: range.start.character
+ )
+ let prefix = String(firstLine.utf16.prefix(upTo: endIndex))
+ return prefix ?? ""
+ }
+ }
+ return ""
+ }()
+
+ let code = prefix + suggestion.code + leftover
+
+ let typedCount = suggestion.startLineIndex == textCursorTracker.cursorPosition.line
+ ? textCursorTracker.cursorPosition.character
+ : 0
+
+ return (
+ code,
+ codeInRange.code,
+ .init(prefix: typedCount, suffix: leftover.utf16.count)
+ )
+ }
+}
+
+// MARK: - Previews
+
+#Preview("Code Block Suggestion Panel") {
+ CodeBlockSuggestionPanelView(suggestion: PresentingCodeSuggestion(
+ code: """
+ LazyVGrid(columns: [GridItem(.fixed(30)), GridItem(.flexible())]) {
+ ForEach(0.. Void
diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift
deleted file mode 100644
index 682d9c79..00000000
--- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift
+++ /dev/null
@@ -1,580 +0,0 @@
-import ComposableArchitecture
-import MarkdownUI
-import SharedUIComponents
-import SuggestionBasic
-import SwiftUI
-
-struct PromptToCodePanel: View {
- let store: StoreOf
-
- var body: some View {
- WithPerceptionTracking {
- VStack(spacing: 0) {
- TopBar(store: store)
-
- Content(store: store)
- .overlay(alignment: .bottom) {
- ActionBar(store: store)
- .padding(.bottom, 8)
- }
-
- Divider()
-
- Toolbar(store: store)
- }
- .background(.ultraThickMaterial)
- .xcodeStyleFrame()
- }
- }
-}
-
-extension PromptToCodePanel {
- struct TopBar: View {
- let store: StoreOf
-
- var body: some View {
- HStack {
- SelectionRangeButton(store: store)
- Spacer()
- CopyCodeButton(store: store)
- }
- .padding(2)
- }
-
- struct SelectionRangeButton: View {
- let store: StoreOf
- var body: some View {
- WithPerceptionTracking {
- Button(action: {
- store.send(.selectionRangeToggleTapped, animation: .linear(duration: 0.1))
- }) {
- let attachedToFilename = store.filename
- let isAttached = store.isAttachedToSelectionRange
- let selectionRange = store.selectionRange
- let color: Color = isAttached ? .accentColor : .secondary.opacity(0.6)
- HStack(spacing: 4) {
- Image(
- systemName: isAttached ? "link" : "character.cursor.ibeam"
- )
- .resizable()
- .aspectRatio(contentMode: .fit)
- .frame(width: 14, height: 14)
- .frame(width: 20, height: 20, alignment: .center)
- .foregroundColor(.white)
- .background(
- color,
- in: RoundedRectangle(
- cornerRadius: 4,
- style: .continuous
- )
- )
-
- if isAttached {
- HStack(spacing: 4) {
- Text(attachedToFilename)
- .lineLimit(1)
- .truncationMode(.middle)
- if let range = selectionRange {
- Text(range.description)
- }
- }.foregroundColor(.primary)
- } else {
- Text("current selection").foregroundColor(.secondary)
- }
- }
- .padding(2)
- .padding(.trailing, 4)
- .overlay {
- RoundedRectangle(cornerRadius: 4, style: .continuous)
- .stroke(color, lineWidth: 1)
- }
- .background {
- RoundedRectangle(cornerRadius: 4, style: .continuous)
- .fill(color.opacity(0.2))
- }
- .padding(2)
- }
- .keyboardShortcut("j", modifiers: [.command])
- .buttonStyle(.plain)
- }
- }
- }
-
- struct CopyCodeButton: View {
- let store: StoreOf
- var body: some View {
- WithPerceptionTracking {
- if !store.code.isEmpty {
- CopyButton {
- store.send(.copyCodeButtonTapped)
- }
- }
- }
- }
- }
- }
-
- struct ActionBar: View {
- let store: StoreOf
-
- var body: some View {
- HStack {
- StopRespondingButton(store: store)
- ActionButtons(store: store)
- }
- }
-
- struct StopRespondingButton: View {
- let store: StoreOf
-
- var body: some View {
- WithPerceptionTracking {
- if store.isResponding {
- Button(action: {
- store.send(.stopRespondingButtonTapped)
- }) {
- HStack(spacing: 4) {
- Image(systemName: "stop.fill")
- Text("Stop")
- }
- .padding(8)
- .background(
- .regularMaterial,
- in: RoundedRectangle(cornerRadius: 6, style: .continuous)
- )
- .overlay {
- RoundedRectangle(cornerRadius: 6, style: .continuous)
- .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
- }
- }
- .buttonStyle(.plain)
- }
- }
- }
- }
-
- struct ActionButtons: View {
- @Perception.Bindable var store: StoreOf
-
- var body: some View {
- WithPerceptionTracking {
- let isResponding = store.isResponding
- let isCodeEmpty = store.code.isEmpty
- let isDescriptionEmpty = store.description.isEmpty
- var isRespondingButCodeIsReady: Bool {
- isResponding
- && !isCodeEmpty
- && !isDescriptionEmpty
- }
- if !isResponding || isRespondingButCodeIsReady {
- HStack {
- Toggle("Continuous Mode", isOn: $store.isContinuous)
- .toggleStyle(.checkbox)
-
- Button(action: {
- store.send(.cancelButtonTapped)
- }) {
- Text("Cancel")
- }
- .buttonStyle(CommandButtonStyle(color: .gray))
- .keyboardShortcut("w", modifiers: [.command])
-
- if !isCodeEmpty {
- Button(action: {
- store.send(.acceptButtonTapped)
- }) {
- Text("Accept(⌘ + ⏎)")
- }
- .buttonStyle(CommandButtonStyle(color: .accentColor))
- .keyboardShortcut(KeyEquivalent.return, modifiers: [.command])
- }
- }
- .padding(8)
- .background(
- .regularMaterial,
- in: RoundedRectangle(cornerRadius: 6, style: .continuous)
- )
- .overlay {
- RoundedRectangle(cornerRadius: 6, style: .continuous)
- .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
- }
- }
- }
- }
- }
- }
-
- struct Content: View {
- let store: StoreOf
- @Environment(\.colorScheme) var colorScheme
- @AppStorage(\.syncPromptToCodeHighlightTheme) var syncHighlightTheme
- @AppStorage(\.codeForegroundColorLight) var codeForegroundColorLight
- @AppStorage(\.codeForegroundColorDark) var codeForegroundColorDark
- @AppStorage(\.codeBackgroundColorLight) var codeBackgroundColorLight
- @AppStorage(\.codeBackgroundColorDark) var codeBackgroundColorDark
-
- var codeForegroundColor: Color? {
- if syncHighlightTheme {
- if colorScheme == .light,
- let color = codeForegroundColorLight.value?.swiftUIColor
- {
- return color
- } else if let color = codeForegroundColorDark.value?.swiftUIColor {
- return color
- }
- }
- return nil
- }
-
- var codeBackgroundColor: Color {
- if syncHighlightTheme {
- if colorScheme == .light,
- let color = codeBackgroundColorLight.value?.swiftUIColor
- {
- return color
- } else if let color = codeBackgroundColorDark.value?.swiftUIColor {
- return color
- }
- }
- return Color.contentBackground
- }
-
- var body: some View {
- WithPerceptionTracking {
- ScrollView {
- VStack(spacing: 0) {
- Spacer(minLength: 60)
- ErrorMessage(store: store)
- DescriptionContent(store: store, codeForegroundColor: codeForegroundColor)
- CodeContent(store: store, codeForegroundColor: codeForegroundColor)
- }
- }
- .background(codeBackgroundColor)
- .scaleEffect(x: 1, y: -1, anchor: .center)
- }
- }
-
- struct ErrorMessage: View {
- let store: StoreOf
-
- var body: some View {
- WithPerceptionTracking {
- if let errorMessage = store.error, !errorMessage.isEmpty {
- Text(errorMessage)
- .multilineTextAlignment(.leading)
- .foregroundColor(.white)
- .padding(.horizontal, 8)
- .padding(.vertical, 4)
- .background(
- Color.red,
- in: RoundedRectangle(cornerRadius: 4, style: .continuous)
- )
- .overlay {
- RoundedRectangle(cornerRadius: 4, style: .continuous)
- .stroke(Color.primary.opacity(0.2), lineWidth: 1)
- }
- .scaleEffect(x: 1, y: -1, anchor: .center)
- }
- }
- }
- }
-
- struct DescriptionContent: View {
- let store: StoreOf
- let codeForegroundColor: Color?
-
- var body: some View {
- WithPerceptionTracking {
- if !store.description.isEmpty {
- Markdown(store.description)
- .textSelection(.enabled)
- .markdownTheme(.gitHub.text {
- BackgroundColor(Color.clear)
- ForegroundColor(codeForegroundColor)
- })
- .padding()
- .frame(maxWidth: .infinity)
- .scaleEffect(x: 1, y: -1, anchor: .center)
- }
- }
- }
- }
-
- struct CodeContent: View {
- let store: StoreOf
- let codeForegroundColor: Color?
-
- @AppStorage(\.wrapCodeInPromptToCode) var wrapCode
-
- var body: some View {
- WithPerceptionTracking {
- if store.code.isEmpty {
- Text(
- store.isResponding
- ? "Thinking..."
- : "Enter your requirement to generate code."
- )
- .foregroundColor(codeForegroundColor?.opacity(0.7) ?? .secondary)
- .padding()
- .multilineTextAlignment(.center)
- .frame(maxWidth: .infinity)
- .scaleEffect(x: 1, y: -1, anchor: .center)
- } else {
- if wrapCode {
- CodeBlockInContent(
- store: store,
- codeForegroundColor: codeForegroundColor
- )
- } else {
- ScrollView(.horizontal) {
- CodeBlockInContent(
- store: store,
- codeForegroundColor: codeForegroundColor
- )
- }
- .modify {
- if #available(macOS 13.0, *) {
- $0.scrollIndicators(.hidden)
- } else {
- $0
- }
- }
- }
- }
- }
- }
-
- struct CodeBlockInContent: View {
- let store: StoreOf
- let codeForegroundColor: Color?
-
- @Environment(\.colorScheme) var colorScheme
- @AppStorage(\.promptToCodeCodeFont) var codeFont
- @AppStorage(\.hideCommonPrecedingSpacesInPromptToCode) var hideCommonPrecedingSpaces
-
- var body: some View {
- WithPerceptionTracking {
- let startLineIndex = store.selectionRange?.start.line ?? 0
- let firstLinePrecedingSpaceCount = store.selectionRange?.start
- .character ?? 0
- CodeBlock(
- code: store.code,
- language: store.language.rawValue,
- startLineIndex: startLineIndex,
- scenario: "promptToCode",
- colorScheme: colorScheme,
- firstLinePrecedingSpaceCount: firstLinePrecedingSpaceCount,
- font: codeFont.value.nsFont,
- droppingLeadingSpaces: hideCommonPrecedingSpaces,
- proposedForegroundColor: codeForegroundColor
- )
- .frame(maxWidth: .infinity)
- .scaleEffect(x: 1, y: -1, anchor: .center)
- }
- }
- }
- }
- }
-
- struct Toolbar: View {
- let store: StoreOf
- @FocusState var focusField: PromptToCode.State.FocusField?
-
- struct RevertButtonState: Equatable {
- var isResponding: Bool
- var canRevert: Bool
- }
-
- var body: some View {
- HStack {
- RevertButton(store: store)
-
- HStack(spacing: 0) {
- InputField(store: store, focusField: $focusField)
- SendButton(store: store)
- }
- .frame(maxWidth: .infinity)
- .background {
- RoundedRectangle(cornerRadius: 6)
- .fill(Color(nsColor: .controlBackgroundColor))
- }
- .overlay {
- RoundedRectangle(cornerRadius: 6)
- .stroke(Color(nsColor: .controlColor), lineWidth: 1)
- }
- .background {
- Button(action: { store.send(.appendNewLineToPromptButtonTapped) }) {
- EmptyView()
- }
- .keyboardShortcut(KeyEquivalent.return, modifiers: [.shift])
- }
- .background {
- Button(action: { focusField = .textField }) {
- EmptyView()
- }
- .keyboardShortcut("l", modifiers: [.command])
- }
- }
- .padding(8)
- .background(.ultraThickMaterial)
- }
-
- struct RevertButton: View {
- let store: StoreOf
- var body: some View {
- WithPerceptionTracking {
- Button(action: {
- store.send(.revertButtonTapped)
- }) {
- Group {
- Image(systemName: "arrow.uturn.backward")
- }
- .padding(6)
- .background {
- Circle().fill(Color(nsColor: .controlBackgroundColor))
- }
- .overlay {
- Circle()
- .stroke(Color(nsColor: .controlColor), lineWidth: 1)
- }
- }
- .buttonStyle(.plain)
- .disabled(store.isResponding || !store.canRevert)
- }
- }
- }
-
- struct InputField: View {
- @Perception.Bindable var store: StoreOf
- var focusField: FocusState.Binding
-
- var body: some View {
- WithPerceptionTracking {
- AutoresizingCustomTextEditor(
- text: $store.prompt,
- font: .systemFont(ofSize: 14),
- isEditable: !store.isResponding,
- maxHeight: 400,
- onSubmit: { store.send(.modifyCodeButtonTapped) }
- )
- .opacity(store.isResponding ? 0.5 : 1)
- .disabled(store.isResponding)
- .focused(focusField, equals: PromptToCode.State.FocusField.textField)
- .bind($store.focusedField, to: focusField)
- }
- .padding(8)
- .fixedSize(horizontal: false, vertical: true)
- }
- }
-
- struct SendButton: View {
- let store: StoreOf
- var body: some View {
- WithPerceptionTracking {
- Button(action: {
- store.send(.modifyCodeButtonTapped)
- }) {
- Image(systemName: "paperplane.fill")
- .padding(8)
- }
- .buttonStyle(.plain)
- .disabled(store.isResponding)
- .keyboardShortcut(KeyEquivalent.return, modifiers: [])
- }
- }
- }
- }
-}
-
-// MARK: - Previews
-
-#Preview("Default") {
- PromptToCodePanel(store: .init(initialState: .init(
- code: """
- ForEach(0..
+ @FocusState var isTextFieldFocused: Bool
+
+ var body: some View {
+ WithPerceptionTracking {
+ PromptToCodeCustomization.CustomizedUI(
+ state: store.$promptToCodeState,
+ delegate: DefaultPromptToCodeContextInputControllerDelegate(store: store),
+ contextInputController: store.contextInputController,
+ isInputFieldFocused: _isTextFieldFocused
+ ) { customizedViews in
+ VStack(spacing: 0) {
+ TopBar(store: store)
+
+ Content(store: store)
+ .safeAreaInset(edge: .bottom) {
+ VStack {
+ StatusBar(store: store)
+
+ ActionBar(store: store)
+
+ if let inputField = customizedViews.contextInputField {
+ inputField
+ } else {
+ Toolbar(store: store)
+ }
+ }
+ }
+ }
+ }
+ }
+ .task {
+ await MainActor.run {
+ isTextFieldFocused = true
+ }
+ }
+ }
+}
+
+extension PromptToCodePanelView {
+ struct TopBar: View {
+ let store: StoreOf
+
+ var body: some View {
+ WithPerceptionTracking {
+ VStack(spacing: 0) {
+ if let previousStep = store.promptToCodeState.history.last {
+ Button(action: {
+ store.send(.revertButtonTapped)
+ }, label: {
+ HStack(spacing: 4) {
+ Text(Image(systemName: "arrow.uturn.backward.circle.fill"))
+ .foregroundStyle(.secondary)
+ Text(previousStep.instruction.string)
+ .lineLimit(1)
+ .truncationMode(.tail)
+ .foregroundStyle(.secondary)
+ Spacer()
+ }
+ .contentShape(Rectangle())
+ })
+ .buttonStyle(.plain)
+ .disabled(store.promptToCodeState.isGenerating)
+ .padding(6)
+
+ Divider()
+ }
+ }
+ .animation(.linear(duration: 0.1), value: store.promptToCodeState.history.count)
+ }
+ }
+
+ struct SelectionRangeButton: View {
+ let store: StoreOf
+ var body: some View {
+ WithPerceptionTracking {
+ Button(action: {
+ store.send(.selectionRangeToggleTapped, animation: .linear(duration: 0.1))
+ }) {
+ let attachedToFilename = store.filename
+ let isAttached = store.promptToCodeState.isAttachedToTarget
+ let color: Color = isAttached ? .accentColor : .secondary.opacity(0.6)
+ HStack(spacing: 4) {
+ Image(
+ systemName: isAttached ? "link" : "character.cursor.ibeam"
+ )
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 14, height: 14)
+ .frame(width: 20, height: 20, alignment: .center)
+ .foregroundColor(.white)
+ .background(
+ color,
+ in: RoundedRectangle(
+ cornerRadius: 4,
+ style: .continuous
+ )
+ )
+
+ if isAttached {
+ HStack(spacing: 4) {
+ Text(attachedToFilename)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ }.foregroundColor(.primary)
+ } else {
+ Text("current selection").foregroundColor(.secondary)
+ }
+ }
+ .padding(2)
+ .padding(.trailing, 4)
+ .overlay {
+ RoundedRectangle(cornerRadius: 6, style: .continuous)
+ .stroke(color, lineWidth: 1)
+ }
+ .background {
+ RoundedRectangle(cornerRadius: 6, style: .continuous)
+ .fill(color.opacity(0.2))
+ }
+ .padding(2)
+ }
+ .keyboardShortcut("j", modifiers: [.command])
+ .buttonStyle(.plain)
+ }
+ }
+ }
+ }
+
+ 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)
+ }
+ }
+
+ struct StopRespondingButton: View {
+ let store: StoreOf
+
+ var body: some View {
+ WithPerceptionTracking {
+ if store.promptToCodeState.isGenerating {
+ Button(action: {
+ store.send(.stopRespondingButtonTapped)
+ }) {
+ HStack(spacing: 4) {
+ Image(systemName: "stop.fill")
+ Text("Stop")
+ }
+ .padding(8)
+ .background(
+ .regularMaterial,
+ in: RoundedRectangle(cornerRadius: 6, style: .continuous)
+ )
+ .overlay {
+ RoundedRectangle(cornerRadius: 6, style: .continuous)
+ .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
+ }
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+ }
+
+ 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)
+ var isRespondingButCodeIsReady: Bool {
+ isResponding && !isCodeEmpty
+ }
+ if !isResponding || isRespondingButCodeIsReady {
+ HStack {
+ Menu {
+ WithPerceptionTracking {
+ Toggle(
+ "Always accept and continue",
+ isOn: $store.isContinuous
+ .animation(.easeInOut(duration: 0.1))
+ )
+ .toggleStyle(.checkbox)
+ }
+
+ chatModelMenu
+ } label: {
+ Image(systemName: "gearshape.fill")
+ .resizable()
+ .scaledToFit()
+ .foregroundStyle(.secondary)
+ .frame(width: 16)
+ .frame(maxHeight: .infinity)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+
+ Button(action: {
+ store.send(.cancelButtonTapped)
+ }) {
+ Text("Cancel")
+ }
+ .buttonStyle(CommandButtonStyle(color: .gray))
+ .keyboardShortcut("w", modifiers: [.command])
+
+ if store.isActiveDocument {
+ if !isCodeEmpty {
+ AcceptButton(store: store)
+ }
+ } else {
+ RevealButton(store: store)
+ }
+ }
+ .fixedSize()
+ .padding(8)
+ .background(
+ .regularMaterial,
+ in: RoundedRectangle(cornerRadius: 6, style: .continuous)
+ )
+ .overlay {
+ RoundedRectangle(cornerRadius: 6, style: .continuous)
+ .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
+ }
+ .animation(
+ .easeInOut(duration: 0.1),
+ value: store.promptToCodeState.snippets
+ )
+ }
+ }
+ }
+
+ @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 {
+ let store: StoreOf
+ @Environment(\.modifierFlags) var modifierFlags
+
+ struct TheButtonStyle: ButtonStyle {
+ func makeBody(configuration: Configuration) -> some View {
+ configuration.label
+ .background(
+ Rectangle()
+ .fill(Color.accentColor.opacity(configuration.isPressed ? 0.8 : 1))
+ )
+ }
+ }
+
+ var body: some View {
+ WithPerceptionTracking {
+ let defaultModeIsContinuous = store.isContinuous
+ let isAttached = store.promptToCodeState.isAttachedToTarget
+
+ HStack(spacing: 0) {
+ Button(action: {
+ switch (
+ modifierFlags.contains(.option),
+ defaultModeIsContinuous
+ ) {
+ case (true, true):
+ store.send(.acceptButtonTapped)
+ case (false, true):
+ store.send(.acceptAndContinueButtonTapped)
+ case (true, false):
+ store.send(.acceptAndContinueButtonTapped)
+ case (false, false):
+ store.send(.acceptButtonTapped)
+ }
+ }) {
+ Group {
+ switch (
+ isAttached,
+ modifierFlags.contains(.option),
+ defaultModeIsContinuous
+ ) {
+ case (true, true, true):
+ Text("Accept(⌥ + ⌘ + ⏎)")
+ case (true, false, true):
+ Text("Accept and Continue(⌘ + ⏎)")
+ case (true, true, false):
+ Text("Accept and Continue(⌥ + ⌘ + ⏎)")
+ case (true, false, false):
+ Text("Accept(⌘ + ⏎)")
+ case (false, true, true):
+ Text("Replace(⌥ + ⌘ + ⏎)")
+ case (false, false, true):
+ Text("Replace and Continue(⌘ + ⏎)")
+ case (false, true, false):
+ Text("Replace and Continue(⌥ + ⌘ + ⏎)")
+ case (false, false, false):
+ Text("Replace(⌘ + ⏎)")
+ }
+ }
+ .padding(.vertical, 4)
+ .padding(.leading, 8)
+ .padding(.trailing, 4)
+ }
+ .buttonStyle(TheButtonStyle())
+ .keyboardShortcut(
+ KeyEquivalent.return,
+ modifiers: modifierFlags
+ .contains(.option) ? [.command, .option] : [.command]
+ )
+
+ Divider()
+
+ Menu {
+ WithPerceptionTracking {
+ if defaultModeIsContinuous {
+ Button(action: {
+ store.send(.acceptButtonTapped)
+ }) {
+ Text("Accept(⌥ + ⌘ + ⏎)")
+ }
+ } else {
+ Button(action: {
+ store.send(.acceptAndContinueButtonTapped)
+ }) {
+ Text("Accept and Continue(⌥ + ⌘ + ⏎)")
+ }
+ }
+ }
+ } label: {
+ Text(Image(systemName: "chevron.down"))
+ .font(.footnote.weight(.bold))
+ .scaleEffect(0.8)
+ .foregroundStyle(.white.opacity(0.8))
+ .frame(maxHeight: .infinity)
+ .padding(.leading, 1)
+ .padding(.trailing, 2)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ }
+ .fixedSize()
+
+ .foregroundColor(.white)
+ .clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous))
+ .background(
+ RoundedRectangle(cornerRadius: 4, style: .continuous)
+ .fill(Color.accentColor)
+ )
+ .overlay {
+ RoundedRectangle(cornerRadius: 4, style: .continuous)
+ .stroke(Color.white.opacity(0.2), style: .init(lineWidth: 1))
+ }
+ }
+ }
+ }
+ }
+
+ struct Content: View {
+ let store: StoreOf
+
+ @Environment(\.colorScheme) var colorScheme
+ @AppStorage(\.syncPromptToCodeHighlightTheme) var syncHighlightTheme
+ @AppStorage(\.codeForegroundColorLight) var codeForegroundColorLight
+ @AppStorage(\.codeForegroundColorDark) var codeForegroundColorDark
+ @AppStorage(\.codeBackgroundColorLight) var codeBackgroundColorLight
+ @AppStorage(\.codeBackgroundColorDark) var codeBackgroundColorDark
+
+ var codeForegroundColor: Color? {
+ if syncHighlightTheme {
+ if colorScheme == .light,
+ let color = codeForegroundColorLight.value?.swiftUIColor
+ {
+ return color
+ } else if let color = codeForegroundColorDark.value?.swiftUIColor {
+ return color
+ }
+ }
+ return nil
+ }
+
+ var codeBackgroundColor: Color {
+ if syncHighlightTheme {
+ if colorScheme == .light,
+ let color = codeBackgroundColorLight.value?.swiftUIColor
+ {
+ return color
+ } else if let color = codeBackgroundColorDark.value?.swiftUIColor {
+ return color
+ }
+ }
+ return Color.contentBackground
+ }
+
+ var body: some View {
+ WithPerceptionTracking {
+ ScrollView {
+ WithPerceptionTracking {
+ VStack(spacing: 0) {
+ VStack(spacing: 0) {
+ let language = store.promptToCodeState.source.language
+ let isAttached = store.promptToCodeState.isAttachedToTarget
+ let lastId = store.promptToCodeState.snippets.last?.id
+ let isGenerating = store.promptToCodeState.isGenerating
+ ForEach(store.scope(
+ state: \.snippetPanels,
+ action: \.snippetPanel
+ )) { snippetStore in
+ WithPerceptionTracking {
+ SnippetPanelView(
+ store: snippetStore,
+ language: language,
+ codeForegroundColor: codeForegroundColor ?? .primary,
+ codeBackgroundColor: codeBackgroundColor,
+ isAttached: isAttached,
+ isGenerating: isGenerating
+ )
+
+ if snippetStore.id != lastId {
+ Divider()
+ }
+ }
+ }
+ }
+
+ Spacer(minLength: 56)
+ }
+ }
+ }
+ .background(codeBackgroundColor)
+ }
+ }
+
+ struct SnippetPanelView: View {
+ let store: StoreOf
+ let language: CodeLanguage
+ let codeForegroundColor: Color
+ let codeBackgroundColor: Color
+ let isAttached: Bool
+ let isGenerating: Bool
+
+ var body: some View {
+ WithPerceptionTracking {
+ 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
+ )
+
+ ErrorMessage(store: store)
+ }
+ }
+ }
+ }
+
+ struct SnippetTitleBar: View {
+ let store: StoreOf
+ let language: CodeLanguage
+ let codeForegroundColor: Color
+ let isAttached: Bool
+ var body: some View {
+ WithPerceptionTracking {
+ HStack {
+ Text(language.rawValue)
+ .foregroundStyle(codeForegroundColor)
+ .font(.callout.bold())
+ .lineLimit(1)
+ if isAttached {
+ Text(String(describing: store.snippet.attachedRange))
+ .foregroundStyle(codeForegroundColor.opacity(0.5))
+ .font(.callout)
+ }
+ Spacer()
+ CopyCodeButton(store: store)
+ }
+ .padding(.leading, 8)
+ }
+ }
+ }
+
+ struct CopyCodeButton: View {
+ let store: StoreOf
+ var body: some View {
+ WithPerceptionTracking {
+ if !store.snippet.modifiedCode.isEmpty {
+ DraggableCopyButton {
+ store.withState {
+ $0.snippet.modifiedCode
+ }
+ }
+ }
+ }
+ }
+ }
+
+ struct ErrorMessage: View {
+ let store: StoreOf
+
+ var body: some View {
+ WithPerceptionTracking {
+ if let errorMessage = store.snippet.error, !errorMessage.isEmpty {
+ (
+ Text(Image(systemName: "exclamationmark.triangle.fill")) +
+ Text(" ") +
+ Text(errorMessage)
+ )
+ .multilineTextAlignment(.leading)
+ .foregroundColor(.red)
+ .padding(.horizontal, 8)
+ .padding(.vertical, 4)
+ }
+ }
+ }
+ }
+
+ struct DescriptionContent: View {
+ let store: StoreOf
+ let codeForegroundColor: Color?
+
+ var body: some View {
+ WithPerceptionTracking {
+ if !store.snippet.description.isEmpty {
+ Markdown(store.snippet.description)
+ .textSelection(.enabled)
+ .markdownTheme(.gitHub.text {
+ BackgroundColor(Color.clear)
+ ForegroundColor(codeForegroundColor)
+ })
+ .padding(.horizontal)
+ .padding(.vertical, 4)
+ .frame(maxWidth: .infinity)
+ }
+ }
+ }
+ }
+
+ struct CodeContent: View {
+ let store: StoreOf
+ let language: CodeLanguage
+ let isGenerating: Bool
+ let codeForegroundColor: Color?
+
+ @AppStorage(\.wrapCodeInPromptToCode) var wrapCode
+
+ var body: some View {
+ WithPerceptionTracking {
+ if !store.snippet.modifiedCode.isEmpty {
+ let wrapCode = wrapCode ||
+ [CodeLanguage.plaintext, .builtIn(.markdown), .builtIn(.shellscript),
+ .builtIn(.tex)].contains(language)
+ if wrapCode {
+ CodeBlockInContent(
+ store: store,
+ language: language,
+ codeForegroundColor: codeForegroundColor,
+ presentAllContent: !isGenerating
+ )
+ } else {
+ MinScrollView {
+ CodeBlockInContent(
+ store: store,
+ language: language,
+ codeForegroundColor: codeForegroundColor,
+ presentAllContent: !isGenerating
+ )
+ }
+ .modify {
+ if #available(macOS 13.0, *) {
+ $0.scrollIndicators(.hidden)
+ } else {
+ $0
+ }
+ }
+ }
+ } else {
+ if isGenerating {
+ Text("Thinking...")
+ .foregroundStyle(.secondary)
+ .padding(.horizontal, 8)
+ .padding(.vertical, 4)
+ } else {
+ Text("Enter your requirements to generate code.")
+ .foregroundStyle(.secondary)
+ .padding(.horizontal, 8)
+ .padding(.vertical, 4)
+ }
+ }
+ }
+ }
+
+ 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
+ @AppStorage(\.hideCommonPrecedingSpacesInPromptToCode) var hideCommonPrecedingSpaces
+
+ var body: some View {
+ WithPerceptionTracking {
+ let startLineIndex = store.snippet.attachedRange.start.line
+ AsyncDiffCodeBlock(
+ code: store.snippet.modifiedCode,
+ originalCode: store.snippet.originalCode,
+ language: language.rawValue,
+ startLineIndex: startLineIndex,
+ scenario: "promptToCode",
+ font: codeFont.value.nsFont,
+ droppingLeadingSpaces: hideCommonPrecedingSpaces,
+ proposedForegroundColor: codeForegroundColor,
+ skipLastOnlyRemovalSection: !presentAllContent
+ )
+ .frame(maxWidth: CGFloat.infinity)
+ }
+ }
+ }
+ }
+ }
+
+ struct Toolbar: View {
+ let store: StoreOf
+ @FocusState var focusField: PromptToCodePanel.State.FocusField?
+
+ var body: some View {
+ HStack {
+ HStack(spacing: 0) {
+ if let contextInputController = store.contextInputController
+ as? DefaultPromptToCodeContextInputController
+ {
+ InputField(
+ store: store,
+ contextInputField: contextInputController,
+ focusField: $focusField
+ )
+ SendButton(store: store)
+ }
+ }
+ .frame(maxWidth: .infinity)
+ .background {
+ RoundedRectangle(cornerRadius: 6)
+ .fill(Color(nsColor: .controlBackgroundColor))
+ }
+ .overlay {
+ RoundedRectangle(cornerRadius: 6)
+ .stroke(Color(nsColor: .controlColor), lineWidth: 1)
+ }
+ .background {
+ Button(action: {
+ (
+ store.contextInputController
+ as? DefaultPromptToCodeContextInputController
+ )?.appendNewLineToPromptButtonTapped()
+ }) {
+ EmptyView()
+ }
+ .keyboardShortcut(KeyEquivalent.return, modifiers: [.shift])
+ }
+ .background {
+ Button(action: { focusField = .textField }) {
+ EmptyView()
+ }
+ .keyboardShortcut("l", modifiers: [.command])
+ }
+ }
+ .padding(8)
+ .background(.ultraThickMaterial)
+ }
+
+ struct InputField: View {
+ @Perception.Bindable var store: StoreOf
+ @Perception.Bindable var contextInputField: DefaultPromptToCodeContextInputController
+ var focusField: FocusState.Binding
+
+ var body: some View {
+ WithPerceptionTracking {
+ AutoresizingCustomTextEditor(
+ text: $contextInputField.instructionString,
+ font: .systemFont(ofSize: 14),
+ isEditable: !store.promptToCodeState.isGenerating,
+ maxHeight: 400,
+ onSubmit: { store.send(.modifyCodeButtonTapped) }
+ )
+ .opacity(store.promptToCodeState.isGenerating ? 0.5 : 1)
+ .disabled(store.promptToCodeState.isGenerating)
+ .focused(focusField, equals: PromptToCodePanel.State.FocusField.textField)
+ .bind($store.focusedField, to: focusField)
+ }
+ .padding(8)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+
+ struct SendButton: View {
+ let store: StoreOf
+ var body: some View {
+ WithPerceptionTracking {
+ Button(action: {
+ store.send(.modifyCodeButtonTapped)
+ }) {
+ Image(systemName: "paperplane.fill")
+ .padding(8)
+ }
+ .buttonStyle(.plain)
+ .disabled(store.promptToCodeState.isGenerating)
+ .keyboardShortcut(KeyEquivalent.return, modifiers: [])
+ }
+ }
+ }
+ }
+
+ 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(ModificationState(
+ source: .init(
+ language: CodeLanguage.builtIn(.swift),
+ documentURL: URL(
+ fileURLWithPath: "path/to/file-name-is-super-long-what-should-we-do-with-it-hah-longer-longer.txt"
+ ),
+ projectRootURL: URL(fileURLWithPath: "path/to/file.txt"),
+ content: "",
+ lines: []
+ ),
+ history: [
+ .init(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)
+ )
+ ),
+ ], instruction: .init("Previous instruction"), references: []),
+ ],
+ snippets: [
+ .init(
+ startLineIndex: 8,
+ originalCode: "print(foo)",
+ modifiedCode: "print(bar)\nprint(baz)",
+ description: "",
+ error: "Error",
+ attachedRange: CursorRange(
+ start: .init(line: 8, character: 0),
+ end: .init(line: 12, character: 2)
+ )
+ ),
+ .init(
+ startLineIndex: 13,
+ originalCode: """
+ struct Foo {
+ var foo: Int
+ }
+ """,
+ modifiedCode: """
+ struct Bar {
+ var bar: String
+ }
+ """,
+ description: "Cool",
+ error: nil,
+ attachedRange: CursorRange(
+ start: .init(line: 13, character: 0),
+ end: .init(line: 12, character: 2)
+ )
+ ),
+ ],
+ extraSystemPrompt: "",
+ isAttachedToTarget: true,
+ references: [
+ ChatMessage.Reference(
+ title: "Foo",
+ content: "struct Foo { var foo: Int }",
+ kind: .symbol(
+ .struct,
+ uri: "file:///path/to/file.txt",
+ startLine: 13,
+ endLine: 13
+ )
+ ),
+ ],
+ )),
+ instruction: nil,
+ commandName: "Generate Code"
+ ), reducer: { PromptToCodePanel() }))
+ .frame(maxWidth: 450, maxHeight: Style.panelHeight)
+ .fixedSize(horizontal: false, vertical: true)
+ .frame(width: 500, height: 500, alignment: .center)
+}
+
+#Preview("Detached With Long File Name") {
+ 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: 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)
+ .fixedSize(horizontal: false, vertical: true)
+ .frame(width: 500, height: 500, alignment: .center)
+}
+
diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/ToastPanelView.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/ToastPanelView.swift
index 5cd6ba23..c7aca342 100644
--- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/ToastPanelView.swift
+++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/ToastPanelView.swift
@@ -12,34 +12,92 @@ struct ToastPanelView: View {
VStack(spacing: 4) {
if !store.alignTopToAnchor {
Spacer()
+ .allowsHitTesting(false)
}
ForEach(store.toast.messages) { message in
- message.content
- .foregroundColor(.white)
- .padding(8)
- .frame(maxWidth: .infinity)
- .background({
- switch message.type {
- case .info: return Color.accentColor
- case .error: return Color(nsColor: .systemRed)
- case .warning: return Color(nsColor: .systemOrange)
+ HStack {
+ message.content
+ .foregroundColor(.white)
+ .textSelection(.enabled)
+
+
+ if !message.buttons.isEmpty {
+ HStack {
+ ForEach(
+ Array(message.buttons.enumerated()),
+ id: \.offset
+ ) { _, button in
+ Button(action: button.action) {
+ button.label
+ .foregroundColor(.white)
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .background {
+ RoundedRectangle(cornerRadius: 4)
+ .stroke(Color.white, lineWidth: 1)
+ }
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .allowsHitTesting(true)
+ }
}
- }() as Color, in: RoundedRectangle(cornerRadius: 8))
- .overlay {
- RoundedRectangle(cornerRadius: 8)
- .stroke(Color.black.opacity(0.1), lineWidth: 1)
}
+ }
+ .padding(8)
+ .frame(maxWidth: .infinity)
+ .background({
+ switch message.type {
+ case .info: return Color.accentColor
+ case .error: return Color(nsColor: .systemRed)
+ case .warning: return Color(nsColor: .systemOrange)
+ }
+ }() as Color, in: RoundedRectangle(cornerRadius: 8))
+ .overlay {
+ RoundedRectangle(cornerRadius: 8)
+ .stroke(Color.black.opacity(0.1), lineWidth: 1)
+ }
}
if store.alignTopToAnchor {
Spacer()
+ .allowsHitTesting(false)
}
}
.colorScheme(store.colorScheme)
.frame(maxWidth: .infinity, maxHeight: .infinity)
- .allowsHitTesting(false)
}
}
}
+#Preview {
+ ToastPanelView(store: .init(initialState: .init(
+ toast: .init(messages: [
+ ToastController.Message(
+ id: UUID(),
+ type: .info,
+ content: Text("Info message"),
+ buttons: [
+ .init(label: Text("Dismiss"), action: {}),
+ .init(label: Text("More info"), action: {}),
+ ]
+ ),
+ ToastController.Message(
+ id: UUID(),
+ type: .error,
+ content: Text("Error message"),
+ buttons: [.init(label: Text("Dismiss"), action: {})]
+ ),
+ ToastController.Message(
+ id: UUID(),
+ type: .warning,
+ content: Text("Warning message"),
+ buttons: [.init(label: Text("Dismiss"), action: {})]
+ ),
+ ])
+ ), reducer: {
+ ToastPanel()
+ }))
+}
+
diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelView.swift b/Core/Sources/SuggestionWidget/SuggestionPanelView.swift
index a1b0f425..b25eb0e9 100644
--- a/Core/Sources/SuggestionWidget/SuggestionPanelView.swift
+++ b/Core/Sources/SuggestionWidget/SuggestionPanelView.swift
@@ -3,7 +3,7 @@ import Foundation
import SwiftUI
struct SuggestionPanelView: View {
- let store: StoreOf
+ let store: StoreOf
struct OverallState: Equatable {
var isPanelDisplayed: Bool
@@ -54,7 +54,7 @@ struct SuggestionPanelView: View {
}
struct Content: View {
- let store: StoreOf
+ let store: StoreOf
@AppStorage(\.suggestionPresentationMode) var suggestionPresentationMode
var body: some View {
@@ -63,7 +63,7 @@ struct SuggestionPanelView: View {
ZStack(alignment: .topLeading) {
switch suggestionPresentationMode {
case .nearbyTextCursor:
- CodeBlockSuggestionPanel(suggestion: content)
+ CodeBlockSuggestionPanelView(suggestion: content)
case .floatingWidget:
EmptyView()
}
diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift
index ab15d53b..09a0ae7a 100644
--- a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift
+++ b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift
@@ -11,7 +11,7 @@ import XcodeInspector
@MainActor
public final class SuggestionWidgetController: NSObject {
- let store: StoreOf
+ let store: StoreOf
let chatTabPool: ChatTabPool
let windowsController: WidgetWindowsController
private var cancellable = Set()
@@ -19,7 +19,7 @@ public final class SuggestionWidgetController: NSObject {
public let dependency: SuggestionWidgetControllerDependency
public init(
- store: StoreOf,
+ store: StoreOf,
chatTabPool: ChatTabPool,
dependency: SuggestionWidgetControllerDependency
) {
@@ -70,17 +70,5 @@ public extension SuggestionWidgetController {
func presentError(_ errorDescription: String) {
store.send(.toastPanel(.toast(.toast(errorDescription, .error, nil))))
}
-
- func presentChatRoom() {
- store.send(.chatPanel(.presentChatPanel(forceDetach: false)))
- }
-
- func presentDetachedGlobalChat() {
- store.send(.chatPanel(.presentChatPanel(forceDetach: true)))
- }
-
- func closeChatRoom() {
-// store.send(.chatPanel(.closeChatPanel))
- }
}
diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetDataSource.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetDataSource.swift
index f7ad662a..2269d095 100644
--- a/Core/Sources/SuggestionWidget/SuggestionWidgetDataSource.swift
+++ b/Core/Sources/SuggestionWidget/SuggestionWidgetDataSource.swift
@@ -1,12 +1,12 @@
import Foundation
public protocol SuggestionWidgetDataSource {
- func suggestionForFile(at url: URL) async -> CodeSuggestionProvider?
+ func suggestionForFile(at url: URL) async -> PresentingCodeSuggestion?
}
struct MockWidgetDataSource: SuggestionWidgetDataSource {
- func suggestionForFile(at url: URL) async -> CodeSuggestionProvider? {
- return CodeSuggestionProvider(
+ func suggestionForFile(at url: URL) async -> PresentingCodeSuggestion? {
+ return PresentingCodeSuggestion(
code: """
func test() {
let x = 1
@@ -17,7 +17,9 @@ struct MockWidgetDataSource: SuggestionWidgetDataSource {
language: "swift",
startLineIndex: 1,
suggestionCount: 3,
- currentSuggestionIndex: 0
+ currentSuggestionIndex: 0,
+ replacingRange: .zero,
+ replacingLines: []
)
}
}
diff --git a/Core/Sources/SuggestionWidget/TextCursorTracker.swift b/Core/Sources/SuggestionWidget/TextCursorTracker.swift
new file mode 100644
index 00000000..6de2dc29
--- /dev/null
+++ b/Core/Sources/SuggestionWidget/TextCursorTracker.swift
@@ -0,0 +1,89 @@
+import Foundation
+import Perception
+import SuggestionBasic
+import SwiftUI
+import XcodeInspector
+
+/// A passive tracker that observe the changes of the source editor content.
+@Perceptible
+final class TextCursorTracker {
+ @MainActor
+ var cursorPosition: CursorPosition { content.cursorPosition }
+ @MainActor
+ var currentLine: String {
+ if content.cursorPosition.line >= 0, content.cursorPosition.line < content.lines.count {
+ content.lines[content.cursorPosition.line]
+ } else {
+ ""
+ }
+ }
+
+ @MainActor
+ var content: SourceEditor.Content = .init(
+ content: "",
+ lines: [],
+ selections: [],
+ cursorPosition: .zero,
+ cursorOffset: 0,
+ lineAnnotations: []
+ )
+
+ @PerceptionIgnored var eventObservationTask: Task?
+
+ init() {
+ observeAppChange()
+ }
+
+ deinit {
+ eventObservationTask?.cancel()
+ }
+
+ var isPreview: Bool {
+ ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
+ }
+
+ private func observeAppChange() {
+ if isPreview { return }
+ 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) async {
+ if isPreview { return }
+ eventObservationTask?.cancel()
+ let content = editor.getLatestEvaluatedContent()
+ 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()
+ await MainActor.run {
+ self.content = content
+ }
+ }
+ }
+ }
+}
+
+struct TextCursorTrackerEnvironmentKey: EnvironmentKey {
+ static var defaultValue: TextCursorTracker = .init()
+}
+
+extension EnvironmentValues {
+ var textCursorTracker: TextCursorTracker {
+ get { self[TextCursorTrackerEnvironmentKey.self] }
+ set { self[TextCursorTrackerEnvironmentKey.self] = newValue }
+ }
+}
+
diff --git a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift
index 41f6c17c..5aed84b3 100644
--- a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift
+++ b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift
@@ -9,6 +9,7 @@ public struct WidgetLocation: Equatable {
var widgetFrame: CGRect
var tabFrame: CGRect
+ var sharedPanelLocation: PanelLocation
var defaultPanelLocation: PanelLocation
var suggestionPanelLocation: PanelLocation?
}
@@ -16,6 +17,7 @@ public struct WidgetLocation: Equatable {
enum UpdateLocationStrategy {
struct AlignToTextCursor {
func framesForWindows(
+ windowFrame: CGRect,
editorFrame: CGRect,
mainScreen: NSScreen,
activeScreen: NSScreen,
@@ -32,6 +34,7 @@ enum UpdateLocationStrategy {
)
else {
return FixedToBottom().framesForWindows(
+ windowFrame: windowFrame,
editorFrame: editorFrame,
mainScreen: mainScreen,
activeScreen: activeScreen,
@@ -42,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,
@@ -50,6 +54,7 @@ enum UpdateLocationStrategy {
}
return HorizontalMovable().framesForWindows(
y: mainScreen.frame.height - frame.maxY,
+ windowFrame: windowFrame,
alignPanelTopToAnchor: nil,
editorFrame: editorFrame,
mainScreen: mainScreen,
@@ -62,6 +67,7 @@ enum UpdateLocationStrategy {
struct FixedToBottom {
func framesForWindows(
+ windowFrame: CGRect,
editorFrame: CGRect,
mainScreen: NSScreen,
activeScreen: NSScreen,
@@ -70,8 +76,9 @@ enum UpdateLocationStrategy {
.value(for: \.preferWidgetToStayInsideEditorWhenWidthGreaterThan),
editorFrameExpendedSize: CGSize = .zero
) -> WidgetLocation {
- return HorizontalMovable().framesForWindows(
+ var frames = HorizontalMovable().framesForWindows(
y: mainScreen.frame.height - editorFrame.maxY + Style.widgetPadding,
+ windowFrame: windowFrame,
alignPanelTopToAnchor: false,
editorFrame: editorFrame,
mainScreen: mainScreen,
@@ -80,12 +87,23 @@ enum UpdateLocationStrategy {
hideCircularWidget: hideCircularWidget,
editorFrameExpendedSize: editorFrameExpendedSize
)
+
+ frames.sharedPanelLocation.frame.size.height = max(
+ frames.defaultPanelLocation.frame.height,
+ editorFrame.height - Style.widgetHeight
+ )
+ frames.defaultPanelLocation.frame.size.height = max(
+ frames.defaultPanelLocation.frame.height,
+ (editorFrame.height - Style.widgetHeight) / 2
+ )
+ return frames
}
}
struct HorizontalMovable {
func framesForWindows(
y: CGFloat,
+ windowFrame: CGRect,
alignPanelTopToAnchor fixedAlignment: Bool?,
editorFrame: CGRect,
mainScreen: NSScreen,
@@ -119,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
@@ -139,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
)
@@ -153,8 +178,12 @@ enum UpdateLocationStrategy {
)
return .init(
- widgetFrame: widgetFrameOnTheRightSide,
+ widgetFrame: widgetFrame,
tabFrame: tabFrame,
+ sharedPanelLocation: .init(
+ frame: panelFrame,
+ alignPanelTop: alignPanelTopToAnchor
+ ),
defaultPanelLocation: .init(
frame: panelFrame,
alignPanelTop: alignPanelTopToAnchor
@@ -212,8 +241,12 @@ enum UpdateLocationStrategy {
height: Style.widgetHeight
)
return .init(
- widgetFrame: widgetFrameOnTheLeftSide,
+ widgetFrame: widgetFrame,
tabFrame: tabFrame,
+ sharedPanelLocation: .init(
+ frame: panelFrame,
+ alignPanelTop: alignPanelTopToAnchor
+ ),
defaultPanelLocation: .init(
frame: panelFrame,
alignPanelTop: alignPanelTopToAnchor
@@ -225,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
)
@@ -239,8 +270,12 @@ enum UpdateLocationStrategy {
height: Style.widgetHeight
)
return .init(
- widgetFrame: widgetFrameOnTheRightSide,
+ widgetFrame: widgetFrame,
tabFrame: tabFrame,
+ sharedPanelLocation: .init(
+ frame: panelFrame,
+ alignPanelTop: alignPanelTopToAnchor
+ ),
defaultPanelLocation: .init(
frame: panelFrame,
alignPanelTop: alignPanelTopToAnchor
@@ -403,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 42837b00..f07816bf 100644
--- a/Core/Sources/SuggestionWidget/WidgetView.swift
+++ b/Core/Sources/SuggestionWidget/WidgetView.swift
@@ -1,11 +1,12 @@
import ActiveApplicationMonitor
import ComposableArchitecture
import Preferences
+import SharedUIComponents
import SuggestionBasic
import SwiftUI
struct WidgetView: View {
- let store: StoreOf
+ let store: StoreOf
@State var isHovering: Bool = false
var onOpenChatClicked: () -> Void = {}
var onCustomCommandClicked: (CustomCommand) -> Void = { _ in }
@@ -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 {
- let store: StoreOf
- @State var processingProgress: Double = 0
+struct WidgetAnimatedCapsule: View {
+ let store: StoreOf
+ 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
+ }
}
}
}
@@ -133,19 +156,23 @@ struct WidgetContextMenu: View {
@AppStorage(\.suggestionFeatureEnabledProjectList) var suggestionFeatureEnabledProjectList
@AppStorage(\.suggestionFeatureDisabledLanguageList) var suggestionFeatureDisabledLanguageList
@AppStorage(\.customCommands) var customCommands
- let store: StoreOf
+ let store: StoreOf
@Dependency(\.xcodeInspector) var xcodeInspector
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()
@@ -259,10 +286,11 @@ struct WidgetView_Preview: PreviewProvider {
isChatPanelDetached: false,
isChatOpen: false
),
- reducer: { CircularWidgetFeature() }
+ reducer: { CircularWidget() }
),
isHovering: false
)
+ .frame(width: Style.widgetWidth, height: Style.widgetHeight)
WidgetView(
store: Store(
@@ -273,10 +301,11 @@ struct WidgetView_Preview: PreviewProvider {
isChatPanelDetached: false,
isChatOpen: false
),
- reducer: { CircularWidgetFeature() }
+ reducer: { CircularWidget() }
),
isHovering: true
)
+ .frame(width: Style.widgetWidth, height: Style.widgetHeight)
WidgetView(
store: Store(
@@ -287,10 +316,11 @@ struct WidgetView_Preview: PreviewProvider {
isChatPanelDetached: false,
isChatOpen: false
),
- reducer: { CircularWidgetFeature() }
+ reducer: { CircularWidget() }
),
isHovering: false
)
+ .frame(width: Style.widgetWidth, height: Style.widgetHeight)
WidgetView(
store: Store(
@@ -301,12 +331,13 @@ struct WidgetView_Preview: PreviewProvider {
isChatPanelDetached: false,
isChatOpen: false
),
- reducer: { CircularWidgetFeature() }
+ reducer: { CircularWidget() }
),
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 2cda76b6..2f70e0e3 100644
--- a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift
+++ b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift
@@ -1,10 +1,11 @@
import AppKit
import AsyncAlgorithms
import ChatTab
-import Combine
import ComposableArchitecture
import Dependencies
import Foundation
+import SharedUIComponents
+import SwiftNavigation
import SwiftUI
import XcodeInspector
@@ -16,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?
@@ -42,7 +42,7 @@ actor WidgetWindowsController: NSObject {
updateWindowStateTask?.cancel()
}
- init(store: StoreOf, chatTabPool: ChatTabPool) {
+ init(store: StoreOf, chatTabPool: ChatTabPool) {
self.store = store
self.chatTabPool = chatTabPool
windows = .init(store: store, chatTabPool: chatTabPool)
@@ -50,28 +50,35 @@ actor WidgetWindowsController: NSObject {
windows.controller = self
}
- @MainActor func send(_ action: WidgetFeature.Action) {
+ @MainActor func send(_ action: Widget.Action) {
store.send(action)
}
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
@@ -81,7 +88,7 @@ actor WidgetWindowsController: NSObject {
}
updateWindowStateTask = Task { [weak self] in
- if let self { await handleXcodeFullscreenChange() }
+ if let self { await handleSpaceChange() }
await withThrowingTaskGroup(of: Void.self) { [weak self] group in
// active space did change
@@ -91,11 +98,15 @@ actor WidgetWindowsController: NSObject {
for await _ in sequence {
guard let self else { return }
try Task.checkCancellation()
- await handleXcodeFullscreenChange()
+ await handleSpaceChange()
}
}
}
}
+
+ Task { @MainActor in
+ windows.chatPanelWindow.isPanelDisplayed = false
+ }
}
}
@@ -113,6 +124,7 @@ private extension WidgetWindowsController {
await hideSuggestionPanelWindow()
}
await adjustChatPanelWindowLevel()
+ await adjustModificationPanelLevel()
}
guard currentApplicationProcessIdentifier != app.processIdentifier else { return }
currentApplicationProcessIdentifier = app.processIdentifier
@@ -126,40 +138,42 @@ 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:
- await handleXcodeFullscreenChange()
+ await handleSpaceChange()
await hideWidgetForTransitions()
await updateWidgetsAndNotifyChangeOfEditor(immediately: true)
case .focusedUIElementChanged:
@@ -201,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
@@ -214,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
@@ -251,7 +265,7 @@ private extension WidgetWindowsController {
extension WidgetWindowsController {
@MainActor
func hidePanelWindows() {
- windows.sharedPanelWindow.alphaValue = 0
+// windows.sharedPanelWindow.alphaValue = 0
windows.suggestionPanelWindow.alphaValue = 0
}
@@ -260,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)
@@ -276,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
@@ -296,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 {
@@ -307,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
@@ -334,32 +358,22 @@ extension WidgetWindowsController {
return WidgetLocation(
widgetFrame: .zero,
tabFrame: .zero,
+ sharedPanelLocation: .init(frame: .zero, alignPanelTop: false),
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
)
}
}
@@ -382,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 = !hasChat
- } else {
- windows.chatPanelWindow.isWindowHidden = noFocus
- }
} else if let activeApp, activeApp.isExtensionService {
let noFocus = {
guard let xcode = latestActiveXcode else { return true }
@@ -417,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
@@ -433,20 +437,11 @@ extension WidgetWindowsController {
0
}
windows.toastWindow.alphaValue = noFocus ? 0 : 1
- if isChatPanelDetached {
- windows.chatPanelWindow.isWindowHidden = !hasChat
- } 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
- }
}
}
}
@@ -478,7 +473,7 @@ extension WidgetWindowsController {
animate: animated
)
windows.sharedPanelWindow.setFrame(
- widgetLocation.defaultPanelLocation.frame,
+ widgetLocation.sharedPanelLocation.frame,
display: false,
animate: animated
)
@@ -502,6 +497,7 @@ extension WidgetWindowsController {
}
await adjustChatPanelWindowLevel()
+ await adjustModificationPanelLevel()
}
let now = Date()
@@ -530,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 {
@@ -576,30 +582,58 @@ 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 handleXcodeFullscreenChange() async {
- let activeXcode = await XcodeInspector.shared.safe.activeXcode
+ func handleSpaceChange() async {
+ 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
[
- windows.chatPanelWindow,
windows.sharedPanelWindow,
windows.suggestionPanelWindow,
windows.widgetWindow,
windows.toastWindow,
].forEach {
- $0.send(.didChangeActiveSpace(fullscreen: isFullscreen))
+ if isXcodeActive {
+ $0.moveToActiveSpace()
+ }
+ }
+
+ if isXcodeActive, !windows.chatPanelWindow.isDetached {
+ windows.chatPanelWindow.moveToActiveSpace()
}
if windows.fullscreenDetector.isOnActiveSpace, xcode?.focusedWindow != nil {
@@ -655,10 +689,9 @@ extension WidgetWindowsController: NSWindowDelegate {
// MARK: - Windows
public final class WidgetWindows {
- let store: StoreOf
+ let store: StoreOf
let chatTabPool: ChatTabPool
weak var controller: WidgetWindowsController?
- let cursorPositionTracker = CursorPositionTracker()
// you should make these window `.transient` so they never show up in the mission control.
@@ -671,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
@@ -689,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(
@@ -715,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(
@@ -728,12 +759,12 @@ public final class WidgetWindows {
state: \.sharedPanelState,
action: \.sharedPanel
)
- ).environment(cursorPositionTracker)
+ ).modifierFlagsMonitor()
)
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
@@ -748,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(
@@ -761,7 +793,7 @@ public final class WidgetWindows {
state: \.suggestionPanelState,
action: \.suggestionPanel
)
- ).environment(cursorPositionTracker)
+ )
)
it.canBecomeKeyChecker = { false }
it.setIsVisible(true)
@@ -780,6 +812,7 @@ public final class WidgetWindows {
self?.store.send(.chatPanel(.hideButtonClicked))
}
)
+ it.hoveringLevel = widgetLevel(1)
it.delegate = controller
return it
}()
@@ -787,15 +820,15 @@ 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
)
it.isReleasedWhenClosed = false
- it.isOpaque = true
+ it.isOpaque = false
it.backgroundColor = .clear
- it.level = widgetLevel(0)
+ it.level = widgetLevel(2)
it.hasShadow = false
it.contentView = NSHostingView(
rootView: ToastPanelView(store: store.scope(
@@ -804,13 +837,12 @@ public final class WidgetWindows {
))
)
it.setIsVisible(true)
- it.ignoresMouseEvents = true
it.canBecomeKeyChecker = { false }
return it
}()
init(
- store: StoreOf,
+ store: StoreOf,
chatTabPool: ChatTabPool
) {
self.store = store
@@ -845,26 +877,14 @@ class WidgetWindow: CanBecomeKeyWindow {
case switchingSpace
}
- enum Action {
- case didChangeActiveSpace(fullscreen: Bool)
- }
+ var hoveringLevel: NSWindow.Level = widgetLevel(0)
+
+ override var isFloatingPanel: Bool { true }
var defaultCollectionBehavior: NSWindow.CollectionBehavior {
[.fullScreenAuxiliary, .transient]
}
- var fullscreenCollectionBehavior: NSWindow.CollectionBehavior {
- // .canJoinAllSpaces is required for macOS 15 (beta?) to display widgets in fullscreen mode.
- // But adding this behavior will create another issue that the widgets will display
- // whenever user switch spaces, so we are setting it only when the window is in fullscreen
- // mode.
- [.fullScreenAuxiliary, .transient, .canJoinAllSpaces]
- }
-
- var switchingSpaceCollectionBehavior: NSWindow.CollectionBehavior {
- [.fullScreenAuxiliary, .transient]
- }
-
var isFullscreen: Bool {
styleMask.contains(.fullScreen)
}
@@ -876,26 +896,59 @@ class WidgetWindow: CanBecomeKeyWindow {
case .none:
collectionBehavior = defaultCollectionBehavior
case .switchingSpace:
- collectionBehavior = switchingSpaceCollectionBehavior
- case let .normal(fullscreen):
- collectionBehavior = fullscreen
- ? fullscreenCollectionBehavior
- : defaultCollectionBehavior
+ collectionBehavior = defaultCollectionBehavior.union(.moveToActiveSpace)
+ case .normal:
+ collectionBehavior = defaultCollectionBehavior
}
}
}
- func send(_ action: Action) {
- switch action {
- case let .didChangeActiveSpace(fullscreen):
- state = .normal(fullscreen: fullscreen)
+ func moveToActiveSpace() {
+ let previousState = state
+ state = .switchingSpace
+ Task { @MainActor in
+ try await Task.sleep(nanoseconds: 50_000_000)
+ 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 29a71e69..44ae7129 100644
--- a/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift
+++ b/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift
@@ -8,13 +8,16 @@ import XCTest
class FilespaceSuggestionInvalidationTests: XCTestCase {
@WorkspaceActor
func prepare(
+ lines: [String],
suggestionText: String,
cursorPosition: CursorPosition,
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: "",
@@ -23,18 +26,22 @@ class FilespaceSuggestionInvalidationTests: XCTestCase {
range: range
),
]
+ filespace.suggestionSourceSnapshot = .init(lines: lines, cursorPosition: cursorPosition)
return filespace
}
func test_text_typing_suggestion_should_be_valid() async throws {
+ let lines = ["\n", "hell\n", "\n"]
let filespace = try await prepare(
+ lines: lines,
suggestionText: "hello man",
cursorPosition: .init(line: 1, character: 0),
range: .init(startPair: (1, 0), endPair: (1, 0))
)
let isValid = await filespace.validateSuggestions(
- lines: ["\n", "hell\n", "\n"],
- cursorPosition: .init(line: 1, character: 4)
+ lines: lines,
+ cursorPosition: .init(line: 1, character: 4),
+ alwaysTrueIfCursorNotMoved: false // TODO: What
)
XCTAssertTrue(isValid)
let suggestion = filespace.presentingSuggestion
@@ -42,76 +49,116 @@ class FilespaceSuggestionInvalidationTests: XCTestCase {
}
func test_text_typing_suggestion_in_the_middle_should_be_valid() async throws {
+ let lines = ["\n", "hell man\n", "\n"]
let filespace = try await prepare(
+ lines: lines,
suggestionText: "hello man",
cursorPosition: .init(line: 1, character: 0),
range: .init(startPair: (1, 0), endPair: (1, 0))
)
let isValid = await filespace.validateSuggestions(
- lines: ["\n", "hell man\n", "\n"],
- cursorPosition: .init(line: 1, character: 4)
+ lines: lines,
+ cursorPosition: .init(line: 1, character: 4),
+ alwaysTrueIfCursorNotMoved: false
)
XCTAssertTrue(isValid)
let suggestion = filespace.presentingSuggestion
XCTAssertNotNil(suggestion)
}
-
+
func test_text_typing_suggestion_with_emoji_in_the_middle_should_be_valid() async throws {
+ let lines = ["\n", "hell🎆🎆 man\n", "\n"]
let filespace = try await prepare(
+ lines: lines,
suggestionText: "hello🎆🎆 man",
cursorPosition: .init(line: 1, character: 0),
range: .init(startPair: (1, 0), endPair: (1, 0))
)
let isValid = await filespace.validateSuggestions(
- lines: ["\n", "hell🎆🎆 man\n", "\n"],
- cursorPosition: .init(line: 1, character: 4)
+ lines: lines,
+ cursorPosition: .init(line: 1, character: 4),
+ alwaysTrueIfCursorNotMoved: false
)
XCTAssertTrue(isValid)
let suggestion = filespace.presentingSuggestion
XCTAssertNotNil(suggestion)
}
-
+
func test_text_typing_suggestion_typed_emoji_in_the_middle_should_be_valid() async throws {
+ let lines = ["\n", "h🎆🎆o ma\n", "\n"]
let filespace = try await prepare(
+ lines: lines,
suggestionText: "h🎆🎆o man",
cursorPosition: .init(line: 1, character: 0),
range: .init(startPair: (1, 0), endPair: (1, 0))
)
let isValid = await filespace.validateSuggestions(
- lines: ["\n", "h🎆🎆o ma\n", "\n"],
- cursorPosition: .init(line: 1, character: 2)
+ lines: lines,
+ cursorPosition: .init(line: 1, character: 2),
+ alwaysTrueIfCursorNotMoved: false
)
XCTAssertTrue(isValid)
let suggestion = filespace.presentingSuggestion
XCTAssertNotNil(suggestion)
}
-
+
func test_text_typing_suggestion_cutting_emoji_in_the_middle_should_be_valid() async throws {
// undefined behavior, must not crash
-
+
+ let lines = ["\n", "h🎆🎆o ma\n", "\n"]
+
let filespace = try await prepare(
+ lines: lines,
suggestionText: "h🎆🎆o man",
cursorPosition: .init(line: 1, character: 0),
range: .init(startPair: (1, 0), endPair: (1, 0))
)
let isValid = await filespace.validateSuggestions(
- lines: ["\n", "h🎆🎆o ma\n", "\n"],
- cursorPosition: .init(line: 1, character: 3)
+ lines: lines,
+ cursorPosition: .init(line: 1, character: 3),
+ alwaysTrueIfCursorNotMoved: false
)
XCTAssertTrue(isValid)
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(
+ lines: lines,
+ suggestionText: "hello man",
+ cursorPosition: .init(line: 1, character: 8),
+ range: .init(startPair: (1, 0), endPair: (1, 8))
+ )
+ let wasValid = await filespace.validateSuggestions(
+ lines: lines,
+ cursorPosition: .init(line: 1, character: 8),
+ alwaysTrueIfCursorNotMoved: false
+ )
+ let isValid = await filespace.validateSuggestions(
+ lines: ["\n", "hello mat\n", "\n"],
+ cursorPosition: .init(line: 1, character: 9),
+ alwaysTrueIfCursorNotMoved: false
+ )
+ XCTAssertTrue(wasValid)
+ XCTAssertFalse(isValid)
+ let suggestion = filespace.presentingSuggestion
+ XCTAssertNil(suggestion)
+ }
+
func test_text_cursor_moved_to_another_line_should_invalidate() async throws {
+ let lines = ["\n", "hell\n", "\n"]
let filespace = try await prepare(
+ lines: lines,
suggestionText: "hello man",
cursorPosition: .init(line: 1, character: 0),
range: .init(startPair: (1, 0), endPair: (1, 0))
)
let isValid = await filespace.validateSuggestions(
- lines: ["\n", "hell\n", "\n"],
- cursorPosition: .init(line: 2, character: 0)
+ lines: lines,
+ cursorPosition: .init(line: 2, character: 0),
+ alwaysTrueIfCursorNotMoved: false
)
XCTAssertFalse(isValid)
let suggestion = filespace.presentingSuggestion
@@ -119,14 +166,17 @@ class FilespaceSuggestionInvalidationTests: XCTestCase {
}
func test_text_cursor_is_invalid_should_invalidate() async throws {
+ let lines = ["\n", "hell\n", "\n"]
let filespace = try await prepare(
+ lines: lines,
suggestionText: "hello man",
cursorPosition: .init(line: 100, character: 0),
range: .init(startPair: (1, 0), endPair: (1, 0))
)
let isValid = await filespace.validateSuggestions(
- lines: ["\n", "hell\n", "\n"],
- cursorPosition: .init(line: 100, character: 4)
+ lines: lines,
+ cursorPosition: .init(line: 100, character: 4),
+ alwaysTrueIfCursorNotMoved: false
)
XCTAssertFalse(isValid)
let suggestion = filespace.presentingSuggestion
@@ -135,13 +185,15 @@ class FilespaceSuggestionInvalidationTests: XCTestCase {
func test_line_content_does_not_match_input_should_invalidate() async throws {
let filespace = try await prepare(
+ lines: ["\n", "hello\n", "\n"],
suggestionText: "hello man",
- cursorPosition: .init(line: 1, character: 0),
- range: .init(startPair: (1, 0), endPair: (1, 0))
+ cursorPosition: .init(line: 1, character: 5),
+ range: .init(startPair: (1, 0), endPair: (1, 5))
)
let isValid = await filespace.validateSuggestions(
lines: ["\n", "helo\n", "\n"],
- cursorPosition: .init(line: 1, character: 4)
+ cursorPosition: .init(line: 1, character: 4),
+ alwaysTrueIfCursorNotMoved: false
)
XCTAssertFalse(isValid)
let suggestion = filespace.presentingSuggestion
@@ -150,13 +202,15 @@ class FilespaceSuggestionInvalidationTests: XCTestCase {
func test_line_content_does_not_match_input_should_invalidate_index_out_of_scope() async throws {
let filespace = try await prepare(
+ lines: ["\n", "hello\n", "\n"],
suggestionText: "hello man",
- cursorPosition: .init(line: 1, character: 0),
- range: .init(startPair: (1, 0), endPair: (1, 0))
+ cursorPosition: .init(line: 1, character: 5),
+ range: .init(startPair: (1, 0), endPair: (1, 5))
)
let isValid = await filespace.validateSuggestions(
lines: ["\n", "helo\n", "\n"],
- cursorPosition: .init(line: 1, character: 100)
+ cursorPosition: .init(line: 1, character: 100),
+ alwaysTrueIfCursorNotMoved: false
)
XCTAssertFalse(isValid)
let suggestion = filespace.presentingSuggestion
@@ -164,38 +218,47 @@ class FilespaceSuggestionInvalidationTests: XCTestCase {
}
func test_finish_typing_the_whole_single_line_suggestion_should_invalidate() async throws {
+ let lines = ["\n", "hello ma\n", "\n"]
let filespace = try await prepare(
+ lines: lines,
suggestionText: "hello man",
- cursorPosition: .init(line: 1, character: 0),
- range: .init(startPair: (1, 0), endPair: (1, 0))
+ cursorPosition: .init(line: 1, character: 8),
+ range: .init(startPair: (1, 0), endPair: (1, 8))
)
let wasValid = await filespace.validateSuggestions(
- lines: ["\n", "hello ma\n", "\n"],
- cursorPosition: .init(line: 1, character: 8)
+ lines: lines,
+ cursorPosition: .init(line: 1, character: 8),
+ alwaysTrueIfCursorNotMoved: false
)
let isValid = await filespace.validateSuggestions(
lines: ["\n", "hello man\n", "\n"],
- cursorPosition: .init(line: 1, character: 9)
+ cursorPosition: .init(line: 1, character: 9),
+ alwaysTrueIfCursorNotMoved: false
)
XCTAssertTrue(wasValid)
XCTAssertFalse(isValid)
let suggestion = filespace.presentingSuggestion
XCTAssertNil(suggestion)
}
-
- func test_finish_typing_the_whole_single_line_suggestion_with_emoji_should_invalidate() async throws {
+
+ func test_finish_typing_the_whole_single_line_suggestion_with_emoji_should_invalidate(
+ ) async throws {
+ let lines = ["\n", "hello m🎆🎆a\n", "\n"]
let filespace = try await prepare(
+ lines: lines,
suggestionText: "hello m🎆🎆an",
cursorPosition: .init(line: 1, character: 0),
range: .init(startPair: (1, 0), endPair: (1, 0))
)
let wasValid = await filespace.validateSuggestions(
- lines: ["\n", "hello m🎆🎆a\n", "\n"],
- cursorPosition: .init(line: 1, character: 12)
+ lines: lines,
+ cursorPosition: .init(line: 1, character: 12),
+ alwaysTrueIfCursorNotMoved: false
)
let isValid = await filespace.validateSuggestions(
lines: ["\n", "hello m🎆🎆an\n", "\n"],
- cursorPosition: .init(line: 1, character: 13)
+ cursorPosition: .init(line: 1, character: 13),
+ alwaysTrueIfCursorNotMoved: false
)
XCTAssertTrue(wasValid)
XCTAssertFalse(isValid)
@@ -205,18 +268,22 @@ class FilespaceSuggestionInvalidationTests: XCTestCase {
func test_finish_typing_the_whole_single_line_suggestion_suggestion_is_incomplete_should_invalidate(
) async throws {
+ let lines = ["\n", "hello ma!!!!\n", "\n"]
let filespace = try await prepare(
+ lines: lines,
suggestionText: "hello man",
cursorPosition: .init(line: 1, character: 0),
range: .init(startPair: (1, 0), endPair: (1, 0))
)
let wasValid = await filespace.validateSuggestions(
- lines: ["\n", "hello ma!!!!\n", "\n"],
- cursorPosition: .init(line: 1, character: 8)
+ lines: lines,
+ cursorPosition: .init(line: 1, character: 8),
+ alwaysTrueIfCursorNotMoved: false
)
let isValid = await filespace.validateSuggestions(
lines: ["\n", "hello man!!!!!\n", "\n"],
- cursorPosition: .init(line: 1, character: 9)
+ cursorPosition: .init(line: 1, character: 9),
+ alwaysTrueIfCursorNotMoved: false
)
XCTAssertTrue(wasValid)
XCTAssertFalse(isValid)
@@ -225,29 +292,36 @@ class FilespaceSuggestionInvalidationTests: XCTestCase {
}
func test_finish_typing_the_whole_multiple_line_suggestion_should_be_valid() async throws {
+ let lines = ["\n", "hello man\n", "\n"]
let filespace = try await prepare(
+ lines: lines,
suggestionText: "hello man\nhow are you?",
cursorPosition: .init(line: 1, character: 0),
range: .init(startPair: (1, 0), endPair: (1, 0))
)
let isValid = await filespace.validateSuggestions(
- lines: ["\n", "hello man\n", "\n"],
- cursorPosition: .init(line: 1, character: 9)
+ lines: lines,
+ cursorPosition: .init(line: 1, character: 9),
+ alwaysTrueIfCursorNotMoved: false
)
XCTAssertTrue(isValid)
let suggestion = filespace.presentingSuggestion
XCTAssertNotNil(suggestion)
}
-
- func test_finish_typing_the_whole_multiple_line_suggestion_with_emoji_should_be_valid() async throws {
+
+ func test_finish_typing_the_whole_multiple_line_suggestion_with_emoji_should_be_valid(
+ ) async throws {
+ let lines = ["\n", "hello m🎆🎆an\n", "\n"]
let filespace = try await prepare(
+ lines: lines,
suggestionText: "hello m🎆🎆an\nhow are you?",
cursorPosition: .init(line: 1, character: 0),
range: .init(startPair: (1, 0), endPair: (1, 0))
)
let isValid = await filespace.validateSuggestions(
- lines: ["\n", "hello m🎆🎆an\n", "\n"],
- cursorPosition: .init(line: 1, character: 13)
+ lines: lines,
+ cursorPosition: .init(line: 1, character: 13),
+ alwaysTrueIfCursorNotMoved: false
)
XCTAssertTrue(isValid)
let suggestion = filespace.presentingSuggestion
@@ -256,18 +330,57 @@ class FilespaceSuggestionInvalidationTests: XCTestCase {
func test_undo_text_to_a_state_before_the_suggestion_was_generated_should_invalidate(
) async throws {
+ let lines = ["\n", "hell\n", "\n"]
let filespace = try await prepare(
+ lines: lines,
suggestionText: "hello man",
cursorPosition: .init(line: 1, character: 5), // generating man from hello
range: .init(startPair: (1, 0), endPair: (1, 5))
)
let isValid = await filespace.validateSuggestions(
- lines: ["\n", "hell\n", "\n"],
- cursorPosition: .init(line: 1, character: 4)
+ lines: lines,
+ cursorPosition: .init(line: 1, character: 4),
+ alwaysTrueIfCursorNotMoved: false
)
XCTAssertFalse(isValid)
let suggestion = filespace.presentingSuggestion
XCTAssertNil(suggestion)
}
+
+ func test_rewriting_the_current_line_by_removing_the_suffix_should_be_valid() async throws {
+ let lines = ["hello world !!!\n"]
+ let filespace = try await prepare(
+ lines: lines,
+ suggestionText: "hello world",
+ cursorPosition: .init(line: 0, character: 15),
+ range: .init(startPair: (0, 0), endPair: (0, 15))
+ )
+ let isValid = await filespace.validateSuggestions(
+ lines: lines,
+ cursorPosition: .init(line: 0, character: 15),
+ alwaysTrueIfCursorNotMoved: false
+ )
+ XCTAssertTrue(isValid)
+ let suggestion = filespace.presentingSuggestion
+ XCTAssertNotNil(suggestion)
+ }
+
+ func test_rewriting_the_current_line_should_be_valid() async throws {
+ let lines = ["hello everyone !!!\n"]
+ let filespace = try await prepare(
+ lines: lines,
+ suggestionText: "hello world !!!",
+ cursorPosition: .init(line: 0, character: 18),
+ range: .init(startPair: (0, 0), endPair: (0, 18))
+ )
+ let isValid = await filespace.validateSuggestions(
+ lines: lines,
+ cursorPosition: .init(line: 0, character: 18),
+ alwaysTrueIfCursorNotMoved: false
+ )
+ XCTAssertTrue(isValid)
+ let suggestion = filespace.presentingSuggestion
+ XCTAssertNotNil(suggestion)
+ }
}
diff --git a/EditorExtension/AcceptPromptToCodeCommand.swift b/EditorExtension/AcceptPromptToCodeCommand.swift
index 51bea4a4..7ac95c35 100644
--- a/EditorExtension/AcceptPromptToCodeCommand.swift
+++ b/EditorExtension/AcceptPromptToCodeCommand.swift
@@ -4,7 +4,7 @@ import SuggestionBasic
import XcodeKit
class AcceptPromptToCodeCommand: NSObject, XCSourceEditorCommand, CommandType {
- var name: String { "Accept Prompt to Code" }
+ var name: String { "Accept Modification" }
func perform(
with invocation: XCSourceEditorCommandInvocation,
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/Helpers.swift b/EditorExtension/Helpers.swift
index 8851c279..beb49c66 100644
--- a/EditorExtension/Helpers.swift
+++ b/EditorExtension/Helpers.swift
@@ -1,5 +1,5 @@
-import SuggestionBasic
import Foundation
+import SuggestionBasic
import XcodeKit
import XPCShared
@@ -19,16 +19,21 @@ extension XCSourceEditorCommandInvocation {
}
func accept(_ updatedContent: UpdatedContent) {
- if let newSelection = updatedContent.newSelection {
+ if !updatedContent.newSelections.isEmpty {
mutateCompleteBuffer(
modifications: updatedContent.modifications,
restoringSelections: false
)
buffer.selections.removeAllObjects()
- buffer.selections.add(XCSourceTextRange(
- start: .init(line: newSelection.start.line, column: newSelection.start.character),
- end: .init(line: newSelection.end.line, column: newSelection.end.character)
- ))
+ for newSelection in updatedContent.newSelections {
+ buffer.selections.add(XCSourceTextRange(
+ start: .init(
+ line: newSelection.start.line,
+ column: newSelection.start.character
+ ),
+ end: .init(line: newSelection.end.line, column: newSelection.end.character)
+ ))
+ }
} else {
mutateCompleteBuffer(
modifications: updatedContent.modifications,
@@ -47,17 +52,17 @@ extension EditorContent {
uti: buffer.contentUTI,
cursorPosition: ((buffer.selections.lastObject as? XCSourceTextRange)?.end).map {
CursorPosition(line: $0.line, character: $0.column)
- } ?? CursorPosition(line: 0, character: 0),
+ } ?? CursorPosition(line: 0, character: 0),
cursorOffset: -1,
selections: buffer.selections.map {
let sl = ($0 as? XCSourceTextRange)?.start.line ?? 0
let sc = ($0 as? XCSourceTextRange)?.start.column ?? 0
let el = ($0 as? XCSourceTextRange)?.end.line ?? 0
let ec = ($0 as? XCSourceTextRange)?.end.column ?? 0
-
+
return Selection(
- start: CursorPosition( line: sl, character: sc ),
- end: CursorPosition( line: el, character: ec )
+ start: CursorPosition(line: sl, character: sc),
+ end: CursorPosition(line: el, character: ec)
)
},
tabSize: buffer.tabWidth,
@@ -96,3 +101,4 @@ extension Task where Failure == Error {
private struct TimeoutError: LocalizedError {
var errorDescription: String? = "Task timed out before completion"
}
+
diff --git a/EditorExtension/PromptToCodeCommand.swift b/EditorExtension/PromptToCodeCommand.swift
index 13e4f3be..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 { "Prompt to 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 c63b9a77..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
@@ -18,6 +19,7 @@ let serviceIdentifier = bundleIdentifierBase + ".ExtensionService"
@main
class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
+ @MainActor
let service = Service.shared
var statusBarItem: NSStatusItem!
var xpcController: XPCController?
@@ -28,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 893e3fe6..c4066a45 100644
--- a/README.md
+++ b/README.md
@@ -8,36 +8,49 @@ Copilot for Xcode is an Xcode Source Editor Extension that provides GitHub Copil
## Features
-- Code Suggestions (powered by GitHub Copilot and Codeium).
-- Chat (powered by OpenAI ChatGPT).
-- Prompt to Code (powered by OpenAI ChatGPT).
-- Custom Commands to extend Chat and Prompt to Code.
+- Code Suggestions
+- Chat
+- Modification
+- Custom Commands to extend Chat and Modification.
## 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)
-
-For frequently asked questions, check [FAQ](https://github.com/intitni/CopilotForXcode/wiki/Frequently-Asked-Questions).
+- [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).
-For more information, check the [wiki](https://github.com/intitni/CopilotForXcode/wiki)
+For more information, check the [Wiki Page](https://copilotforxcode.intii.com/wiki).
## Prerequisites
@@ -92,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
@@ -197,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.
@@ -233,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
@@ -244,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
@@ -275,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
@@ -297,24 +302,16 @@ 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.
-#### Prompt to Code 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
-- Prompt to Code: Open a prompt to code window, where you can use natural language to write or edit selected code.
-- Accept Prompt to Code: Accept the result of prompt to 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 Prompt to Code 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:
-- Prompt to Code: Run Prompt to Code 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.
+- 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.
- Custom Chat: Open the chat window and immediately send a message, if provided. You can overwrite the entire system prompt through the system prompt field.
- Single Round Dialog: Send a message to a temporary chat. Useful when you want to run a terminal command with `/run`.
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 1d83d1c6..c9ebe525 100644
--- a/TestPlan.xctestplan
+++ b/TestPlan.xctestplan
@@ -24,93 +24,93 @@
"testTargets" : [
{
"target" : {
- "containerPath" : "container:Core",
- "identifier" : "ServiceTests",
- "name" : "ServiceTests"
+ "containerPath" : "container:Tool",
+ "identifier" : "SharedUIComponentsTests",
+ "name" : "SharedUIComponentsTests"
}
},
{
"target" : {
- "containerPath" : "container:Core",
- "identifier" : "SuggestionInjectorTests",
- "name" : "SuggestionInjectorTests"
+ "containerPath" : "container:Tool",
+ "identifier" : "ActiveDocumentChatContextCollectorTests",
+ "name" : "ActiveDocumentChatContextCollectorTests"
}
},
{
"target" : {
- "containerPath" : "container:Core",
- "identifier" : "SuggestionWidgetTests",
- "name" : "SuggestionWidgetTests"
+ "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"
}
},
{
@@ -120,6 +120,27 @@
"name" : "GitHubCopilotServiceTests"
}
},
+ {
+ "target" : {
+ "containerPath" : "container:Tool",
+ "identifier" : "OpenAIServiceTests",
+ "name" : "OpenAIServiceTests"
+ }
+ },
+ {
+ "target" : {
+ "containerPath" : "container:Core",
+ "identifier" : "SuggestionWidgetTests",
+ "name" : "SuggestionWidgetTests"
+ }
+ },
+ {
+ "target" : {
+ "containerPath" : "container:Core",
+ "identifier" : "KeyBindingManagerTests",
+ "name" : "KeyBindingManagerTests"
+ }
+ },
{
"target" : {
"containerPath" : "container:Tool",
@@ -130,29 +151,36 @@
{
"target" : {
"containerPath" : "container:Tool",
- "identifier" : "XcodeInspectorTests",
- "name" : "XcodeInspectorTests"
+ "identifier" : "WebSearchServiceTests",
+ "name" : "WebSearchServiceTests"
}
},
{
"target" : {
"containerPath" : "container:Tool",
- "identifier" : "SuggestionProviderTests",
- "name" : "SuggestionProviderTests"
+ "identifier" : "XcodeInspectorTests",
+ "name" : "XcodeInspectorTests"
}
},
{
"target" : {
"containerPath" : "container:Core",
- "identifier" : "KeyBindingManagerTests",
- "name" : "KeyBindingManagerTests"
+ "identifier" : "ChatServiceTests",
+ "name" : "ChatServiceTests"
}
},
{
"target" : {
"containerPath" : "container:Tool",
- "identifier" : "SuggestionBasicTests",
- "name" : "SuggestionBasicTests"
+ "identifier" : "SuggestionInjectorTests",
+ "name" : "SuggestionInjectorTests"
+ }
+ },
+ {
+ "target" : {
+ "containerPath" : "container:Tool",
+ "identifier" : "TokenEncoderTests",
+ "name" : "TokenEncoderTests"
}
}
],
diff --git a/Tool/Package.swift b/Tool/Package.swift
index fdf323a0..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"]),
@@ -20,7 +20,9 @@ let package = Package(
name: "ChatContextCollector",
targets: ["ChatContextCollector", "ActiveDocumentChatContextCollector"]
),
- .library(name: "SuggestionBasic", targets: ["SuggestionBasic"]),
+ .library(name: "SuggestionBasic", targets: ["SuggestionBasic", "SuggestionInjector"]),
+ .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"]),
@@ -47,6 +49,15 @@ let package = Package(
.library(name: "DebounceFunction", targets: ["DebounceFunction"]),
.library(name: "AsyncPassthroughSubject", targets: ["AsyncPassthroughSubject"]),
.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.
@@ -62,16 +73,19 @@ let package = Package(
.package(url: "https://github.com/intitni/Highlightr", branch: "master"),
.package(
url: "https://github.com/pointfreeco/swift-composable-architecture",
- from: "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"),
@@ -94,6 +108,12 @@ let package = Package(
.target(name: "ObjectiveCExceptionHandling"),
+ .target(name: "JoinJSON"),
+ .testTarget(name: "JoinJSONTests", dependencies: ["JoinJSON"]),
+
+ .target(name: "CodeDiff", dependencies: ["SuggestionBasic"]),
+ .testTarget(name: "CodeDiffTests", dependencies: ["CodeDiff"]),
+
.target(
name: "CustomAsyncAlgorithms",
dependencies: [
@@ -119,6 +139,14 @@ let package = Package(
)]
),
+ .target(
+ name: "CustomCommandTemplateProcessor",
+ dependencies: [
+ "XcodeInspector",
+ "SuggestionBasic",
+ ]
+ ),
+
.target(name: "DebounceFunction"),
.target(
@@ -160,6 +188,15 @@ let package = Package(
]
),
+ .target(
+ name: "SuggestionInjector",
+ dependencies: ["SuggestionBasic"]
+ ),
+ .testTarget(
+ name: "SuggestionInjectorTests",
+ dependencies: ["SuggestionInjector"]
+ ),
+
.target(
name: "AIModel",
dependencies: [
@@ -171,7 +208,7 @@ let package = Package(
name: "SuggestionBasicTests",
dependencies: ["SuggestionBasic"]
),
-
+
.target(
name: "ChatBasic",
dependencies: [
@@ -182,7 +219,37 @@ let package = Package(
]
),
- .target(name: "AXExtension"),
+ .target(
+ name: "ModificationBasic",
+ dependencies: [
+ "SuggestionBasic",
+ "ChatBasic",
+ .product(name: "CodableWrappers", package: "CodableWrappers"),
+ .product(
+ name: "ComposableArchitecture",
+ package: "swift-composable-architecture"
+ ),
+ ]
+ ),
+ .testTarget(name: "ModificationBasicTests", dependencies: ["ModificationBasic"]),
+
+ .target(
+ name: "PromptToCodeCustomization",
+ dependencies: [
+ "ModificationBasic",
+ "SuggestionBasic",
+ "ChatBasic",
+ .product(
+ name: "ComposableArchitecture",
+ package: "swift-composable-architecture"
+ ),
+ ]
+ ),
+
+ .target(
+ name: "AXExtension",
+ dependencies: ["Logger"]
+ ),
.target(
name: "AXNotificationStream",
@@ -232,6 +299,7 @@ let package = Package(
"Preferences",
"SuggestionBasic",
"DebounceFunction",
+ "CodeDiff",
.product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
]
),
@@ -264,6 +332,7 @@ let package = Package(
"SuggestionProvider",
"XPCShared",
"BuiltinExtension",
+ "SuggestionInjector",
]
),
@@ -294,6 +363,17 @@ let package = Package(
]
),
+ .target(
+ name: "CommandHandler",
+ dependencies: [
+ "XcodeInspector",
+ "Preferences",
+ "ChatBasic",
+ "ModificationBasic",
+ .product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
+ ]
+ ),
+
// MARK: - Services
.target(
@@ -303,12 +383,18 @@ let package = Package(
"ObjectiveCExceptionHandling",
"USearchIndex",
"ChatBasic",
+ .product(name: "JSONRPC", package: "JSONRPC"),
.product(name: "Parsing", package: "swift-parsing"),
.product(name: "SwiftSoup", package: "SwiftSoup"),
]
),
- .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",
@@ -320,6 +406,16 @@ let package = Package(
.testTarget(name: "SuggestionProviderTests", dependencies: ["SuggestionProvider"]),
+ .target(
+ name: "RAGChatAgent",
+ dependencies: [
+ "ChatBasic",
+ "ChatContextCollector",
+ "OpenAIService",
+ "Preferences",
+ ]
+ ),
+
// MARK: - GitHub Copilot
.target(
@@ -334,6 +430,7 @@ let package = Package(
"BuiltinExtension",
"Toast",
"SuggestionProvider",
+ .product(name: "JSONRPC", package: "JSONRPC"),
.product(name: "LanguageServerProtocol", package: "LanguageServerProtocol"),
.product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"),
],
@@ -357,6 +454,8 @@ let package = Package(
"XcodeInspector",
"BuiltinExtension",
"ChatTab",
+ "SharedUIComponents",
+ .product(name: "JSONRPC", package: "JSONRPC"),
.product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"),
]
),
@@ -372,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"),
@@ -397,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
@@ -417,6 +524,7 @@ let package = Package(
.target(
name: "ActiveDocumentChatContextCollector",
dependencies: [
+ "ASTParser",
"ChatContextCollector",
"OpenAIService",
"Preferences",
diff --git a/Tool/Sources/AIModel/ChatModel.swift b/Tool/Sources/AIModel/ChatModel.swift
index 3695343a..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 {
@@ -38,21 +39,34 @@ public struct ChatModel: Codable, Equatable, Identifiable {
public struct OpenAIInfo: Codable, Equatable {
@FallbackDecoding
public var organizationID: String
+ @FallbackDecoding
+ public var projectID: String
- public init(organizationID: String = "") {
+ public init(organizationID: String = "", projectID: String = "") {
self.organizationID = organizationID
+ 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
@@ -62,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
@@ -72,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
@@ -83,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 = "",
@@ -90,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
}
}
@@ -138,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"
}
}
}
@@ -165,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 28a22227..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
@@ -21,7 +22,7 @@ public extension AXUIElement {
var value: String {
(try? copyValue(key: kAXValueAttribute)) ?? ""
}
-
+
var intValue: Int? {
(try? copyValue(key: kAXValueAttribute))
}
@@ -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
@@ -134,9 +166,27 @@ public extension AXUIElement {
var isFullScreen: Bool {
(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,19 +242,36 @@ public extension AXUIElement {
if let target = child.child(
identifier: identifier,
title: title,
- role: role
+ role: role,
+ depth: depth + 1
) { return target }
}
return nil
}
- func children(where match: (AXUIElement) -> Bool) -> [AXUIElement] {
+ /// Get children that match the requirement
+ ///
+ /// - important: If the element has a lot of descendant nodes, it will heavily affect the
+ /// **performance of Xcode**. Please make use ``AXUIElement\traverse(_:)`` instead.
+ @available(
+ *,
+ deprecated,
+ renamed: "traverse(_:)",
+ message: "Please make use ``AXUIElement\traverse(_:)`` instead."
+ )
+ 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
}
@@ -208,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
}
}
@@ -233,6 +320,97 @@ public extension AXUIElement {
}
}
+public extension AXUIElement {
+ enum SearchNextStep {
+ case skipDescendants
+ case skipSiblings(Info)
+ case skipDescendantsAndSiblings
+ case continueSearching(Info)
+ case stopSearching
+ }
+
+ /// 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 },
+ 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,
+ 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(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 loop
+ case .stopSearching:
+ return .stopSearching
+ case .continueSearching, .skipDescendants:
+ continue loop
+ }
+ }
+
+ return nextStep
+ }
+ }
+
+ _ = _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)
+ }
+ }
+}
+
// MARK: - Helper
public extension AXUIElement {
@@ -264,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 b50f3bf4..2011360a 100644
--- a/Tool/Sources/AppActivator/AppActivator.swift
+++ b/Tool/Sources/AppActivator/AppActivator.swift
@@ -3,7 +3,7 @@ import Dependencies
import XcodeInspector
public extension NSWorkspace {
- static func activateThisApp(delay: TimeInterval = 0.3) {
+ static func activateThisApp(delay: TimeInterval = 0.10) {
Task { @MainActor in
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
@@ -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
new file mode 100644
index 00000000..1b6b835d
--- /dev/null
+++ b/Tool/Sources/ChatBasic/ChatAgent.swift
@@ -0,0 +1,83 @@
+import Foundation
+
+public enum ChatAgentResponse {
+ public enum Content {
+ case text(String)
+ }
+
+ public enum ActionResult {
+ case success(String)
+ case failure(String)
+ }
+
+ /// Post the status of the current message.
+ 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 references: [ChatMessage.Reference]
+ public var topics: [ChatMessage.Reference]
+ public var agentInstructions: String? = nil
+
+ public init(
+ text: String,
+ history: [ChatMessage],
+ references: [ChatMessage.Reference],
+ topics: [ChatMessage.Reference],
+ agentInstructions: String? = nil
+ ) {
+ self.text = text
+ self.history = history
+ 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 1131c247..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.
@@ -43,14 +70,18 @@ public extension ChatGPTFunction {
argumentsJsonString: String,
reportProgress: @escaping ReportProgress
) async throws -> Result {
- do {
- let arguments = try JSONDecoder()
- .decode(Arguments.self, from: argumentsJsonString.data(using: .utf8) ?? Data())
- return try await call(arguments: arguments, reportProgress: reportProgress)
- } catch {
- await reportProgress("Error: Failed to decode arguments. \(error.localizedDescription)")
- throw error
- }
+ let arguments = try await {
+ do {
+ return try JSONDecoder()
+ .decode(Arguments.self, from: argumentsJsonString.data(using: .utf8) ?? Data())
+ } catch {
+ await reportProgress(
+ "Error: Failed to decode arguments. \(error.localizedDescription)"
+ )
+ throw error
+ }
+ }()
+ return try await call(arguments: arguments, reportProgress: reportProgress)
}
}
@@ -64,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 }
@@ -85,12 +106,7 @@ public extension ChatGPTArgumentsCollectingFunction {
assertionFailure("This function is only used to get a structured output from the bot.")
return ""
}
-
- @available(
- *,
- deprecated,
- message: "This function is only used to get a structured output from the bot."
- )
+
func call(
argumentsJsonString: String,
reportProgress: @escaping ReportProgress
@@ -100,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 689fc0e5..ab5f04a4 100644
--- a/Tool/Sources/ChatBasic/ChatMessage.swift
+++ b/Tool/Sources/ChatBasic/ChatMessage.swift
@@ -1,17 +1,24 @@
-import CodableWrappers
+@preconcurrency import CodableWrappers
import Foundation
-public struct ChatMessage: Equatable, Codable {
+/// A chat message that can be sent or received.
+public struct ChatMessage: Equatable, Codable, Sendable {
public typealias ID = String
- public enum Role: String, Codable, Equatable {
+ /// The role of a message.
+ 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.
}
- public struct FunctionCall: Codable, Equatable {
+ /// A function call that can be made by the bot.
+ public struct FunctionCall: Codable, Equatable, Sendable {
+ /// The name of the function.
public var name: String
+ /// Arguments in the format of a JSON string.
public var arguments: String
public init(name: String, arguments: String) {
self.name = name
@@ -19,10 +26,14 @@ public struct ChatMessage: Equatable, Codable {
}
}
- public struct ToolCall: Codable, Equatable, Identifiable {
+ /// A tool call that can be made by the bot.
+ public struct ToolCall: Codable, Equatable, Identifiable, Sendable {
public var id: String
+ /// The type of tool call.
public var type: String
+ /// The actual function call.
public var function: FunctionCall
+ /// The response of the function call.
public var response: ToolCallResponse
public init(
id: String,
@@ -37,8 +48,11 @@ public struct ChatMessage: Equatable, Codable {
}
}
- public struct ToolCallResponse: Codable, Equatable {
+ /// The response of a tool call
+ public struct ToolCallResponse: Codable, Equatable, Sendable {
+ /// The content of the response.
public var content: String
+ /// The summary of the response to display in UI.
public var summary: String?
public init(content: String, summary: String?) {
self.content = content
@@ -46,52 +60,80 @@ public struct ChatMessage: Equatable, Codable {
}
}
- public struct Reference: Codable, Equatable {
- public enum Kind: String, Codable {
- case `class`
- case `struct`
- case `enum`
- case `actor`
- case `protocol`
- case `extension`
- case `case`
- case property
- case `typealias`
- case function
- case method
+ /// A reference to include in a chat message.
+ public struct Reference: Codable, Equatable, Identifiable, Sendable {
+ /// The kind of reference.
+ public enum Kind: Codable, Equatable, Sendable {
+ public enum Symbol: String, Codable, Sendable {
+ case `class`
+ case `struct`
+ case `enum`
+ case `actor`
+ case `protocol`
+ case `extension`
+ case `case`
+ case property
+ case `typealias`
+ case function
+ case method
+ }
+
+ /// Code symbol.
+ case symbol(Symbol, uri: String, startLine: Int?, endLine: Int?)
+ /// Some text.
case text
- case webpage
- case other
+ /// A webpage.
+ case webpage(uri: String)
+ /// A text file.
+ 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
- public var subTitle: String
- public var uri: String
+ /// The content of the reference.
public var content: String
- public var startLine: Int?
- public var endLine: Int?
+ /// The kind of the reference.
@FallbackDecoding
public var kind: Kind
public init(
+ id: String = UUID().uuidString,
title: String,
- subTitle: String,
content: String,
- uri: String,
- startLine: Int?,
- endLine: Int?,
kind: Kind
) {
+ self.id = id
self.title = title
- self.subTitle = subTitle
self.content = content
- self.uri = uri
- self.startLine = startLine
- self.endLine = endLine
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
@@ -117,6 +159,12 @@ 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?
+
/// The number of tokens of this message.
public var tokensCount: Int?
@@ -124,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 }
@@ -134,15 +189,21 @@ public struct ChatMessage: Equatable, Codable {
public init(
id: String = UUID().uuidString,
+ senderId: String? = nil,
+ responseTo: String? = nil,
role: Role,
content: String?,
name: String? = nil,
toolCalls: [ToolCall]? = nil,
summary: String? = nil,
tokenCount: Int? = nil,
- references: [Reference] = []
+ references: [Reference] = [],
+ images: [Image] = [],
+ cacheIfPossible: Bool = false
) {
self.role = role
+ self.senderId = senderId
+ self.responseTo = responseTo
self.content = content
self.name = name
self.toolCalls = toolCalls
@@ -150,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 static var defaultValue: ChatMessage.Reference.Kind { .other }
+public struct ReferenceKindFallback: FallbackValueProvider, Sendable {
+ public static var defaultValue: ChatMessage.Reference.Kind { .other(kind: "Unknown") }
+}
+
+public struct ReferenceIDFallback: FallbackValueProvider, Sendable {
+ public static var defaultValue: String { UUID().uuidString }
}
-public struct ChatMessageRoleFallback: FallbackValueProvider {
+public struct ChatMessageRoleFallback: FallbackValueProvider, Sendable {
public static var defaultValue: ChatMessage.Role { .user }
}
diff --git a/Tool/Sources/ChatBasic/ChatPlugin.swift b/Tool/Sources/ChatBasic/ChatPlugin.swift
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
new file mode 100644
index 00000000..a15db29d
--- /dev/null
+++ b/Tool/Sources/CodeDiff/CodeDiff.swift
@@ -0,0 +1,608 @@
+import Foundation
+import SuggestionBasic
+
+public struct CodeDiff {
+ public init() {}
+
+ public typealias LineDiff = CollectionDifference
+
+ public struct SnippetDiff: Equatable, CustomStringConvertible {
+ public struct Change: Equatable {
+ public var offset: Int
+ public var element: String
+ }
+
+ public struct Line: Equatable {
+ public enum Diff: Equatable {
+ case unchanged
+ case mutated(changes: [Change])
+ }
+
+ 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, 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]
+
+ public func line(at index: Int, in keyPath: KeyPath) -> Line? {
+ var previousSectionEnd = 0
+ for section in sections {
+ let lines = section[keyPath: keyPath]
+ let index = index - previousSectionEnd
+ if index < lines.endIndex {
+ return lines[index]
+ }
+ previousSectionEnd += lines.endIndex
+ }
+ 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 {
+ typealias Change = LineDiff.Change
+ let diffByCharacter = text.difference(from: oldText)
+ var result = [Change]()
+
+ var current: Change?
+ for item in diffByCharacter {
+ if let this = current {
+ switch (this, item) {
+ case let (.insert(offset, element, associatedWith), .insert(offsetB, elementB, _))
+ where offset + element.count == offsetB:
+ current = .insert(
+ offset: offset,
+ element: element + String(elementB),
+ associatedWith: associatedWith
+ )
+ continue
+ case let (.remove(offset, element, associatedWith), .remove(offsetB, elementB, _))
+ where offset - 1 == offsetB:
+ current = .remove(
+ offset: offsetB,
+ element: String(elementB) + element,
+ associatedWith: associatedWith
+ )
+ continue
+ default:
+ result.append(this)
+ }
+ }
+
+ current = switch item {
+ case let .insert(offset, element, associatedWith):
+ .insert(offset: offset, element: String(element), associatedWith: associatedWith)
+ case let .remove(offset, element, associatedWith):
+ .remove(offset: offset, element: String(element), associatedWith: associatedWith)
+ }
+ }
+
+ if let current {
+ result.append(current)
+ }
+
+ return .init(result) ?? [].difference(from: [])
+ }
+
+ public func diff(snippet: String, from oldSnippet: String) -> SnippetDiff {
+ let newLines = snippet.splitByNewLine(omittingEmptySubsequences: false)
+ let oldLines = oldSnippet.splitByNewLine(omittingEmptySubsequences: false)
+ let diffByLine = newLines.difference(from: oldLines)
+
+ let groups = generateDiffSections(diffByLine)
+
+ var oldLineIndex = 0
+ var newLineIndex = 0
+ var sectionIndex = 0
+ var result = SnippetDiff(sections: [])
+
+ while oldLineIndex < oldLines.endIndex || newLineIndex < newLines.endIndex {
+ 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..)
+ -> [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 {
+ groups.append(.init(
+ remove: [],
+ insert: insertSection.changes.map { .init(change: $0) }
+ ))
+ insertOffset += insertSection.changes.count
+ insertIndex += 1
+ }
+ } else if let removeSection {
+ groups.append(.init(
+ remove: removeSection.changes.map { .init(change: $0) },
+ insert: []
+ ))
+ removeIndex += 1
+ } else if let insertSection {
+ groups.append(.init(
+ remove: [],
+ insert: insertSection.changes.map { .init(change: $0) }
+ ))
+ insertIndex += 1
+ }
+ }
+
+ return groups
+ }
+}
+
+private extension Array {
+ subscript(safe index: Int) -> Element? {
+ guard index >= 0, index < count else { return nil }
+ return self[index]
+ }
+
+ subscript(safe index: Int, fallback fallback: Element) -> Element {
+ guard index >= 0, index < count else { return fallback }
+ return self[index]
+ }
+}
+
+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
+
+struct SnippetDiffPreview: View {
+ let originalCode: String
+ let newCode: String
+
+ var body: some View {
+ HStack(alignment: .top) {
+ let (original, new) = generateTexts()
+ block(original)
+ Divider()
+ block(new)
+ }
+ .padding()
+ .font(.body.monospaced())
+ }
+
+ @ViewBuilder
+ func block(_ code: [AttributedString]) -> some View {
+ VStack(alignment: .leading) {
+ if !code.isEmpty {
+ ForEach(0.. (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)
+ let string = NSMutableAttributedString(string: text)
+ if case let .mutated(changes) = $0.diff {
+ string.addAttribute(
+ .backgroundColor,
+ value: NSColor.green.withAlphaComponent(0.1),
+ range: NSRange(location: 0, length: text.count)
+ )
+
+ for diffItem in changes {
+ string.addAttribute(
+ .backgroundColor,
+ value: NSColor.green.withAlphaComponent(0.5),
+ range: NSRange(
+ location: diffItem.offset,
+ length: min(
+ text.count - diffItem.offset,
+ diffItem.element.count
+ )
+ )
+ )
+ }
+ }
+ return string
+ }
+ }
+
+ let original = diff.sections.flatMap {
+ $0.oldSnippet.map {
+ let text = $0.text.trimmingCharacters(in: .newlines)
+ let string = NSMutableAttributedString(string: text)
+ if case let .mutated(changes) = $0.diff {
+ string.addAttribute(
+ .backgroundColor,
+ value: NSColor.red.withAlphaComponent(0.1),
+ range: NSRange(location: 0, length: text.count)
+ )
+
+ for diffItem in changes {
+ string.addAttribute(
+ .backgroundColor,
+ value: NSColor.red.withAlphaComponent(0.5),
+ range: NSRange(
+ location: diffItem.offset,
+ length: min(text.count - diffItem.offset, diffItem.element.count)
+ )
+ )
+ }
+ }
+
+ return string
+ }
+ }
+
+ return (original.map(AttributedString.init), new.map(AttributedString.init))
+ }
+}
+
+struct LineDiffPreview: View {
+ let originalCode: String
+ let newCode: String
+
+ var body: some View {
+ VStack(alignment: .leading) {
+ let (original, new) = generateTexts()
+ Text(original)
+ Divider()
+ Text(new)
+ }
+ .padding()
+ .font(.body.monospaced())
+ }
+
+ func generateTexts() -> (original: AttributedString, new: AttributedString) {
+ let diff = CodeDiff().diff(text: newCode, from: originalCode)
+ let original = NSMutableAttributedString(string: originalCode)
+ let new = NSMutableAttributedString(string: newCode)
+
+ for item in diff {
+ switch item {
+ case let .insert(offset, element, _):
+ new.addAttribute(
+ .backgroundColor,
+ value: NSColor.green.withAlphaComponent(0.5),
+ range: NSRange(location: offset, length: element.count)
+ )
+ case let .remove(offset, element, _):
+ original.addAttribute(
+ .backgroundColor,
+ value: NSColor.red.withAlphaComponent(0.5),
+ range: NSRange(location: offset, length: element.count)
+ )
+ }
+ }
+
+ return (.init(original), .init(new))
+ }
+}
+
+#Preview("Line Diff") {
+ let originalCode = """
+ let foo = Foo() // yes
+ """
+ let newCode = """
+ var foo = Bar()
+ """
+
+ return LineDiffPreview(originalCode: originalCode, newCode: newCode)
+}
+
+#Preview("Snippet Diff") {
+ let originalCode = """
+ let foo = Foo()
+ print(foo)
+ // do something
+ foo.foo()
+ func zoo() {}
+ """
+ let newCode = """
+ var foo = Bar()
+ // do something
+ foo.bar()
+ func zoo() {
+ print("zoo")
+ }
+ """
+
+ 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
new file mode 100644
index 00000000..f5067668
--- /dev/null
+++ b/Tool/Sources/CommandHandler/CommandHandler.swift
@@ -0,0 +1,200 @@
+import ComposableArchitecture
+import Dependencies
+import Foundation
+import ModificationBasic
+import Preferences
+import SuggestionBasic
+import Toast
+import XcodeInspector
+
+/// Provides an interface to handle commands.
+public protocol CommandHandler {
+ // MARK: Suggestion
+
+ func presentSuggestions(_ suggestions: [CodeSuggestion]) async
+ func presentPreviousSuggestion() async
+ func presentNextSuggestion() async
+ func rejectSuggestions() async
+ func acceptSuggestion() async
+ func acceptActiveSuggestionLineInGroup(atIndex index: Int?) async
+ func dismissSuggestion() async
+ func generateRealtimeSuggestions(sourceEditor: SourceEditor?) async
+
+ // MARK: Chat
+
+ func openChat(forceDetach: Bool, activateThisApp: Bool)
+ func sendChatMessage(_ message: String) async
+
+ // MARK: Modification
+
+ func acceptModification() async
+ func presentModification(state: Shared) async
+
+ // MARK: Custom Command
+
+ func handleCustomCommand(_ command: CustomCommand) async
+
+ // 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 extension DependencyValues {
+ /// In production, you need to override the command handler globally by setting
+ /// ``UniversalCommandHandler.shared.commandHandler``.
+ ///
+ /// In tests, you can use ``withDependency`` to mock it.
+ var commandHandler: CommandHandler {
+ get { self[CommandHandlerDependencyKey.self] }
+ set { self[CommandHandlerDependencyKey.self] = newValue }
+ }
+}
+
+public final class UniversalCommandHandler: CommandHandler {
+ public static let shared: UniversalCommandHandler = .init()
+
+ public var commandHandler: CommandHandler = NOOPCommandHandler()
+
+ private init() {}
+
+ public func presentSuggestions(_ suggestions: [SuggestionBasic.CodeSuggestion]) async {
+ await commandHandler.presentSuggestions(suggestions)
+ }
+
+ public func presentPreviousSuggestion() async {
+ await commandHandler.presentPreviousSuggestion()
+ }
+
+ public func presentNextSuggestion() async {
+ await commandHandler.presentNextSuggestion()
+ }
+
+ public func rejectSuggestions() async {
+ await commandHandler.rejectSuggestions()
+ }
+
+ 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()
+ }
+
+ public func generateRealtimeSuggestions(sourceEditor: SourceEditor?) async {
+ await commandHandler.generateRealtimeSuggestions(sourceEditor: sourceEditor)
+ }
+
+ public func openChat(forceDetach: Bool, activateThisApp: Bool) {
+ commandHandler.openChat(forceDetach: forceDetach, activateThisApp: activateThisApp)
+ }
+
+ public func sendChatMessage(_ message: String) async {
+ await commandHandler.sendChatMessage(message)
+ }
+
+ public func acceptModification() async {
+ await commandHandler.acceptModification()
+ }
+
+ public func presentModification(state: Shared) async {
+ await commandHandler.presentModification(state: state)
+ }
+
+ public func handleCustomCommand(_ command: CustomCommand) async {
+ await commandHandler.handleCustomCommand(command)
+ }
+
+ 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(_ 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