diff --git a/.gitignore b/.gitignore
index eb99039a..488722ae 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,6 @@
+# IDE
+.idea
+
# Created by
https://www.toptal.com/developers/gitignore/api/xcode,macos,swift,swiftpackagemanager
# Edit at
diff --git a/.gitmodules b/.gitmodules
index a091985c..e69de29b 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,3 +0,0 @@
-[submodule "Pro"]
- path = Pro
- url = git@github.com:intitni/CopilotForXcodePro.git
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/CommunicationBridge/main.swift b/CommunicationBridge/main.swift
index 45f2324c..bb449566 100644
--- a/CommunicationBridge/main.swift
+++ b/CommunicationBridge/main.swift
@@ -1,14 +1,19 @@
+import AppKit
import Foundation
+class AppDelegate: NSObject, NSApplicationDelegate {}
+
let bundleIdentifierBase = Bundle(url: Bundle.main.bundleURL.appendingPathComponent(
"CopilotForXcodeExtensionService.app"
))?.object(forInfoDictionaryKey: "BUNDLE_IDENTIFIER_BASE") as? String ?? "com.intii.CopilotForXcode"
let serviceIdentifier = bundleIdentifierBase + ".CommunicationBridge"
-
+let appDelegate = AppDelegate()
let delegate = ServiceDelegate()
let listener = NSXPCListener(machServiceName: serviceIdentifier)
listener.delegate = delegate
listener.resume()
-RunLoop.main.run()
+let app = NSApplication.shared
+app.delegate = appDelegate
+app.run()
diff --git a/Config.xcconfig b/Config.xcconfig
index 1c65b4c7..81d6e2ba 100644
--- a/Config.xcconfig
+++ b/Config.xcconfig
@@ -3,7 +3,7 @@ SLASH = /
HOST_APP_NAME = Copilot for Xcode
BUNDLE_IDENTIFIER_BASE = com.intii.CopilotForXcode
-SPARKLE_FEED_URL = https:$(SLASH)$(SLASH)raw.githubusercontent.com/intitni/CopilotForXcode/main/appcast.xml
+SPARKLE_FEED_URL = https:$(SLASH)$(SLASH)copilotforxcode.intii.com/appcast.xml
SPARKLE_PUBLIC_KEY = WDzm5GHnc6c8kjeJEgX5GuGiPpW6Lc/ovGjLnrrZvPY=
APPLICATION_SUPPORT_FOLDER = com.intii.CopilotForXcode
EXTENSION_BUNDLE_NAME = Copilot
diff --git a/Copilot for Xcode.xcodeproj/project.pbxproj b/Copilot for Xcode.xcodeproj/project.pbxproj
index 04bebd6a..056e5761 100644
--- a/Copilot for Xcode.xcodeproj/project.pbxproj
+++ b/Copilot for Xcode.xcodeproj/project.pbxproj
@@ -205,7 +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 = ""; };
- C83E5DED2A38CD8C0071506D /* Makefile */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.make; path = Makefile; 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 = ""; };
@@ -235,6 +235,7 @@
C87B03AA293B262E00C77EAE /* PreviousSuggestionCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviousSuggestionCommand.swift; sourceTree = ""; };
C887BC832965D96000931567 /* DEVELOPMENT.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = DEVELOPMENT.md; sourceTree = ""; };
C89E75C22A46FB32000DD64F /* AppDelegate+Menu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+Menu.swift"; sourceTree = ""; };
+ C8BE64922EB9B42E00EDB2D7 /* OverlayWindow */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = OverlayWindow; sourceTree = ""; };
C8CD828229B88006008D044D /* TestPlan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = TestPlan.xctestplan; sourceTree = ""; };
C8DCEFFF29CE11D500FDDDD7 /* OpenChat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenChat.swift; sourceTree = ""; };
C8DD9CB02BC673F80036641C /* CloseIdleTabsCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseIdleTabsCommand.swift; sourceTree = ""; };
@@ -335,7 +336,6 @@
C887BC832965D96000931567 /* DEVELOPMENT.md */,
C8520308293D805800460097 /* README.md */,
C82E38492A1F025F00D4EADF /* LICENSE */,
- C83E5DED2A38CD8C0071506D /* Makefile */,
C8F103292A7A365000D28F4F /* launchAgent.plist */,
C8738B6D2BE4F3E800609E7F /* bridgeLaunchAgent.plist */,
C81E867D296FE4420026E908 /* Version.xcconfig */,
@@ -343,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 */,
@@ -773,17 +775,18 @@
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = "$(APP_BUILD)";
+ DEAD_CODE_STRIPPING = YES;
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;
@@ -800,17 +803,18 @@
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = "$(APP_BUILD)";
+ DEAD_CODE_STRIPPING = YES;
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;
@@ -854,9 +858,11 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
+ DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
@@ -915,9 +921,11 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
+ DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
@@ -945,19 +953,21 @@
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = "$(APP_BUILD)";
+ DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_ASSET_PATHS = "\"Copilot for Xcode/Preview Content\"";
DEVELOPMENT_TEAM = 5YKZ4Y3DAW;
ENABLE_HARDENED_RUNTIME = YES;
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;
@@ -977,19 +987,21 @@
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = "$(APP_BUILD)";
+ DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_ASSET_PATHS = "\"Copilot for Xcode/Preview Content\"";
DEVELOPMENT_TEAM = 5YKZ4Y3DAW;
ENABLE_HARDENED_RUNTIME = YES;
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)";
@@ -1002,9 +1014,10 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
+ 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;
@@ -1015,9 +1028,10 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
+ 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;
@@ -1033,6 +1047,7 @@
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = "$(APP_BUILD)";
+ DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 5YKZ4Y3DAW;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
@@ -1046,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)";
@@ -1065,6 +1080,7 @@
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = "$(APP_BUILD)";
+ DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 5YKZ4Y3DAW;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
@@ -1078,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)";
@@ -1098,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;
@@ -1117,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/Copilot for Xcode.xcscheme b/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/Copilot for Xcode.xcscheme
index 3ff50e3c..436e8938 100644
--- a/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/Copilot for Xcode.xcscheme
+++ b/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/Copilot for Xcode.xcscheme
@@ -1,6 +1,6 @@
-
-
$(EXTENSION_BUNDLE_NAME)
HOST_APP_NAME
$(HOST_APP_NAME)
+ NSAppTransportSecurity
+
+ NSAllowsArbitraryLoads
+
+
SUEnableJavaScript
YES
SUFeedURL
diff --git a/Core/.swiftpm/xcode/xcshareddata/xcschemes/Service.xcscheme b/Core/.swiftpm/xcode/xcshareddata/xcschemes/Service.xcscheme
index 93f9a75c..112ea84d 100644
--- a/Core/.swiftpm/xcode/xcshareddata/xcschemes/Service.xcscheme
+++ b/Core/.swiftpm/xcode/xcshareddata/xcschemes/Service.xcscheme
@@ -1,6 +1,6 @@
[Target.Dependency] {
- if isProIncluded {
- // include the pro package
- return self + targetNames.map { Target.Dependency.product(name: $0, package: "Pro") }
- }
- return self
- }
-}
-
-extension [Package.Dependency] {
- var pro: [Package.Dependency] {
- if isProIncluded {
- // include the pro package
- return self + [.package(path: "../Pro/Pro")]
- }
- return self
- }
-}
-
-let isProIncluded: Bool = {
- func isProIncluded(file: StaticString = #file) -> Bool {
- let filePath = "\(file)"
- let fileURL = URL(fileURLWithPath: filePath)
- let rootURL = fileURL
- .deletingLastPathComponent()
- .deletingLastPathComponent()
- let confURL = rootURL.appendingPathComponent("PLUS")
- if !FileManager.default.fileExists(atPath: confURL.path) {
- return false
- }
- do {
- if let content = try String(
- data: Data(contentsOf: confURL),
- encoding: .utf8
- ) {
- if content.hasPrefix("YES") {
- return true
- }
- }
- return false
- } catch {
- return false
- }
- }
-
- return isProIncluded()
-}()
-
// MARK: - Package
let package = Package(
name: "Core",
- platforms: [.macOS(.v12)],
+ platforms: [.macOS(.v13)],
products: [
.library(
name: "Service",
targets: [
"Service",
- "SuggestionInjector",
"FileChangeChecker",
"LaunchAgentManager",
"UpdateChecker",
@@ -89,18 +37,22 @@ 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
.package(url: "https://github.com/intitni/KeyboardShortcuts", branch: "main"),
+ .package(url: "https://github.com/intitni/CGEventOverride", from: "1.2.1"),
+ .package(url: "https://github.com/intitni/Highlightr", branch: "master"),
].pro,
targets: [
// MARK: - Main
@@ -110,11 +62,11 @@ let package = Package(
dependencies: [
.product(name: "XPCShared", package: "Tool"),
.product(name: "SuggestionProvider", package: "Tool"),
- .product(name: "SuggestionModel", package: "Tool"),
+ .product(name: "SuggestionBasic", package: "Tool"),
.product(name: "Logger", package: "Tool"),
.product(name: "Preferences", package: "Tool"),
- ].pro([
- "ProClient",
+ ].proCore([
+ "LicenseManagement",
])
),
.target(
@@ -127,20 +79,27 @@ let package = Package(
"ServiceUpdateMigration",
"ChatGPTChatTab",
"PlusFeatureFlag",
+ "KeyBindingManager",
+ "XcodeThemeController",
.product(name: "XPCShared", package: "Tool"),
.product(name: "SuggestionProvider", package: "Tool"),
.product(name: "Workspace", package: "Tool"),
+ .product(name: "WorkspaceSuggestionService", package: "Tool"),
.product(name: "UserDefaultsObserver", package: "Tool"),
.product(name: "AppMonitoring", package: "Tool"),
- .product(name: "SuggestionModel", 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",
])
@@ -150,10 +109,9 @@ let package = Package(
dependencies: [
"Service",
"Client",
- "SuggestionInjector",
.product(name: "XPCShared", package: "Tool"),
.product(name: "SuggestionProvider", package: "Tool"),
- .product(name: "SuggestionModel", package: "Tool"),
+ .product(name: "SuggestionBasic", package: "Tool"),
.product(name: "Preferences", package: "Tool"),
]
),
@@ -169,7 +127,8 @@ let package = Package(
.product(name: "SuggestionProvider", package: "Tool"),
.product(name: "Toast", package: "Tool"),
.product(name: "SharedUIComponents", package: "Tool"),
- .product(name: "SuggestionModel", 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"),
@@ -187,28 +146,21 @@ let package = Package(
dependencies: [
.product(name: "UserDefaultsObserver", package: "Tool"),
.product(name: "Preferences", package: "Tool"),
- .product(name: "SuggestionModel", package: "Tool"),
- .product(name: "SuggestionProvider", package: "Tool")
+ .product(name: "SuggestionBasic", package: "Tool"),
+ .product(name: "SuggestionProvider", package: "Tool"),
].pro([
"ProExtension",
])
),
- .target(
- name: "SuggestionInjector",
- dependencies: [.product(name: "SuggestionModel", 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: "SuggestionModel", package: "Tool"),
+ .product(name: "SuggestionBasic", package: "Tool"),
.product(name: "OpenAIService", package: "Tool"),
.product(name: "AppMonitoring", package: "Tool"),
.product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
@@ -223,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"),
@@ -274,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"),
@@ -281,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"),
@@ -322,61 +274,100 @@ let package = Package(
])
),
- // MARK: - Chat Plugins
+ // MAKR: - Chat Context Collector
.target(
- name: "MathChatPlugin",
+ name: "WebChatContextCollector",
dependencies: [
- "ChatPlugin",
- .product(name: "OpenAIService", package: "Tool"),
+ .product(name: "ChatContextCollector", 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"),
+ .product(name: "Preferences", package: "Tool"),
],
- path: "Sources/ChatPlugins/SearchChatPlugin"
+ path: "Sources/ChatContextCollectors/WebChatContextCollector"
),
.target(
- name: "ShortcutChatPlugin",
+ name: "SystemInfoChatContextCollector",
dependencies: [
- "ChatPlugin",
- .product(name: "Parsing", package: "swift-parsing"),
- .product(name: "Terminal", package: "Tool"),
+ .product(name: "ChatContextCollector", package: "Tool"),
+ .product(name: "OpenAIService", package: "Tool"),
],
- path: "Sources/ChatPlugins/ShortcutChatPlugin"
+ path: "Sources/ChatContextCollectors/SystemInfoChatContextCollector"
),
- // MAKR: - Chat Context Collector
+ // MARK: Key Binding
.target(
- name: "WebChatContextCollector",
+ name: "KeyBindingManager",
dependencies: [
- .product(name: "ChatContextCollector", package: "Tool"),
- .product(name: "LangChain", package: "Tool"),
- .product(name: "OpenAIService", package: "Tool"),
- .product(name: "ExternalServices", package: "Tool"),
+ .product(name: "CommandHandler", package: "Tool"),
+ .product(name: "Workspace", package: "Tool"),
.product(name: "Preferences", package: "Tool"),
- ],
- path: "Sources/ChatContextCollectors/WebChatContextCollector"
+ .product(name: "Logger", package: "Tool"),
+ .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(
- name: "SystemInfoChatContextCollector",
+ name: "XcodeThemeController",
dependencies: [
- .product(name: "ChatContextCollector", package: "Tool"),
- .product(name: "OpenAIService", package: "Tool"),
- ],
- path: "Sources/ChatContextCollectors/SystemInfoChatContextCollector"
+ .product(name: "Preferences", package: "Tool"),
+ .product(name: "AppMonitoring", package: "Tool"),
+ .product(name: "Highlightr", package: "Highlightr"),
+ ]
),
]
)
+extension [Target.Dependency] {
+ func pro(_ targetNames: [String]) -> [Target.Dependency] {
+ if isProIncluded {
+ return self + targetNames.map { Target.Dependency.product(name: $0, package: "Pro") }
+ }
+ 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"), .package(path: "../../Pro/ProCore")]
+ }
+ return self
+ }
+}
+
+var isProIncluded: Bool {
+ func isProIncluded(file: StaticString = #file) -> Bool {
+ let filePath = "\(file)"
+ let fileURL = URL(fileURLWithPath: filePath)
+ let rootURL = fileURL
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+ let confURL = rootURL.appendingPathComponent("PLUS")
+ return FileManager.default.fileExists(atPath: confURL.path)
+ }
+
+ return isProIncluded()
+}
+
diff --git a/Core/Sources/ChatContextCollectors/WebChatContextCollector/QueryWebsiteFunction.swift b/Core/Sources/ChatContextCollectors/WebChatContextCollector/QueryWebsiteFunction.swift
index e4a22903..0620123c 100644
--- a/Core/Sources/ChatContextCollectors/WebChatContextCollector/QueryWebsiteFunction.swift
+++ b/Core/Sources/ChatContextCollectors/WebChatContextCollector/QueryWebsiteFunction.swift
@@ -1,3 +1,4 @@
+import ChatBasic
import Foundation
import LangChain
import OpenAIService
@@ -15,6 +16,10 @@ struct QueryWebsiteFunction: ChatGPTFunction {
var botReadableContent: String {
return answers.joined(separator: "\n")
}
+
+ var userReadableContent: ChatGPTFunctionResultUserReadableContent {
+ .text(botReadableContent)
+ }
}
var name: String {
@@ -54,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,
@@ -76,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 99c88312..60a5504e 100644
--- a/Core/Sources/ChatContextCollectors/WebChatContextCollector/SearchFunction.swift
+++ b/Core/Sources/ChatContextCollectors/WebChatContextCollector/SearchFunction.swift
@@ -1,7 +1,8 @@
-import BingSearchService
+import ChatBasic
import Foundation
import OpenAIService
import Preferences
+import WebSearchService
struct SearchFunction: ChatGPTFunction {
static let dateFormatter = {
@@ -16,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
@@ -71,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/ChatContextCollectors/WebChatContextCollector/WebChatContextCollector.swift b/Core/Sources/ChatContextCollectors/WebChatContextCollector/WebChatContextCollector.swift
index 851fdcf7..848ca0fa 100644
--- a/Core/Sources/ChatContextCollectors/WebChatContextCollector/WebChatContextCollector.swift
+++ b/Core/Sources/ChatContextCollectors/WebChatContextCollector/WebChatContextCollector.swift
@@ -1,3 +1,4 @@
+import ChatBasic
import ChatContextCollector
import Foundation
import OpenAIService
diff --git a/Core/Sources/ChatGPTChatTab/Chat.swift b/Core/Sources/ChatGPTChatTab/Chat.swift
index 80c4da5b..28443876 100644
--- a/Core/Sources/ChatGPTChatTab/Chat.swift
+++ b/Core/Sources/ChatGPTChatTab/Chat.swift
@@ -1,6 +1,9 @@
+import AppKit
+import ChatBasic
import ChatService
import ComposableArchitecture
import Foundation
+import MarkdownUI
import OpenAIService
import Preferences
import Terminal
@@ -40,12 +43,14 @@ public struct DisplayedChatMessage: Equatable {
public var id: String
public var role: Role
public var text: String
+ public var markdownContent: MarkdownContent
public var references: [Reference] = []
public init(id: String, role: Role, text: String, references: [Reference]) {
self.id = id
self.role = role
self.text = text
+ markdownContent = .init(text)
self.references = references
}
}
@@ -66,6 +71,8 @@ struct Chat {
var isReceivingMessage = false
var chatMenu = ChatMenu.State()
var focusedField: Field?
+ var isEnabled = true
+ var isPinnedToBottom = true
enum Field: String, Hashable {
case textField
@@ -77,6 +84,7 @@ struct Chat {
case appear
case refresh
+ case setIsEnabled(Bool)
case sendButtonTapped
case returnButtonTapped
case stopRespondingButtonTapped
@@ -84,6 +92,8 @@ struct Chat {
case deleteMessageButtonTapped(MessageID)
case resendMessageButtonTapped(MessageID)
case setAsExtraPromptButtonTapped(MessageID)
+ case manuallyScrolledUp
+ case scrollToBottomButtonTapped
case focusOnTextField
case referenceClicked(DisplayedChatMessage.Reference)
@@ -115,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)
}
@@ -143,6 +151,10 @@ struct Chat {
await send(.chatMenu(.refresh))
}
+ case let .setIsEnabled(isEnabled):
+ state.isEnabled = isEnabled
+ return .none
+
case .sendButtonTapped:
guard !state.typedMessage.isEmpty else { return .none }
let message = state.typedMessage
@@ -193,18 +205,26 @@ 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)
}
}
+ case .manuallyScrolledUp:
+ state.isPinnedToBottom = false
+ return .none
+
+ case .scrollToBottomButtonTapped:
+ state.isPinnedToBottom = true
+ return .none
+
case .focusOnTextField:
state.focusedField = .textField
return .none
@@ -231,7 +251,7 @@ struct Chat {
let debouncedHistoryChange = TimedDebounceFunction(duration: 0.2) {
await send(.historyChanged)
}
-
+
for await _ in stream {
await debouncedHistoryChange()
}
@@ -322,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 ?? [] {
@@ -365,6 +377,9 @@ struct Chat {
case .isReceivingMessageChanged:
state.isReceivingMessage = service.isReceivingMessage
+ if service.isReceivingMessage {
+ state.isPinnedToBottom = true
+ }
return .none
case .systemPromptChanged:
@@ -482,9 +497,59 @@ private actor TimedDebounceFunction {
}
}
}
-
+
func fire() async {
lastFireTime = Date()
await block()
}
}
+
+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 768c064b..9114a5dd 100644
--- a/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift
+++ b/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift
@@ -70,12 +70,19 @@ struct ChatContextMenu: View {
@ViewBuilder
var chatModel: some View {
+ let allModels = chatModels + [.init(
+ id: "com.github.copilot",
+ name: "GitHub Copilot Language Server",
+ format: .openAI,
+ info: .init()
+ )]
+
Menu("Chat Model") {
Button(action: {
store.send(.chatModelIdOverrideSelected(nil))
}) {
HStack {
- if let defaultModel = chatModels
+ if let defaultModel = allModels
.first(where: { $0.id == defaultChatModelId })
{
Text("Default (\(defaultModel.name))")
@@ -88,7 +95,7 @@ struct ChatContextMenu: View {
}
}
- if let id = store.chatModelIdOverride, !chatModels.map(\.id).contains(id) {
+ if let id = store.chatModelIdOverride, !allModels.map(\.id).contains(id) {
Button(action: {
store.send(.chatModelIdOverrideSelected(nil))
}) {
@@ -101,7 +108,7 @@ struct ChatContextMenu: View {
Divider()
- ForEach(chatModels, id: \.id) { model in
+ ForEach(allModels, id: \.id) { model in
Button(action: {
store.send(.chatModelIdOverrideSelected(model.id))
}) {
@@ -152,26 +159,26 @@ struct ChatContextMenu: View {
@ViewBuilder
var defaultScopes: some View {
Menu("Default Scopes") {
+ Button(action: {
+ store.send(.resetDefaultScopesButtonTapped)
+ }) {
+ Text("Reset Default Scopes")
+ }
+
+ Divider()
+
+ ForEach(ChatService.Scope.allCases, id: \.rawValue) { value in
Button(action: {
- store.send(.resetDefaultScopesButtonTapped)
+ store.send(.toggleScope(value))
}) {
- Text("Reset Default Scopes")
- }
-
- Divider()
-
- ForEach(ChatService.Scope.allCases, id: \.rawValue) { value in
- Button(action: {
- store.send(.toggleScope(value))
- }) {
- HStack {
- Text("@" + value.rawValue)
- if store.defaultScopes.contains(value) {
- Image(systemName: "checkmark")
- }
+ HStack {
+ Text("@" + value.rawValue)
+ if store.defaultScopes.contains(value) {
+ Image(systemName: "checkmark")
}
}
}
+ }
}
}
diff --git a/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift b/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift
index 9aade9d0..ad2c6887 100644
--- a/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift
+++ b/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift
@@ -76,10 +76,7 @@ public class ChatGPTChatTab: ChatTab {
return (try? JSONEncoder().encode(state)) ?? Data()
}
- public static func restore(
- from data: Data,
- externalDependency: Void
- ) async throws -> any ChatTabBuilder {
+ public static func restore(from data: Data) async throws -> any ChatTabBuilder {
let state = try JSONDecoder().decode(RestorableState.self, from: data)
let builder = Builder(title: "Chat") { @MainActor tab in
tab.service.configuration.overriding = state.configuration
@@ -96,7 +93,7 @@ public class ChatGPTChatTab: ChatTab {
return builder
}
- public static func chatBuilders(externalDependency: Void) -> [ChatTabBuilder] {
+ public static func chatBuilders() -> [ChatTabBuilder] {
let customCommands = UserDefaults.shared.value(for: \.customCommands).compactMap {
command in
if case .customChat = command.feature {
@@ -105,7 +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: "Legacy Chat", customCommand: nil)
}
@MainActor
@@ -133,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 }
@@ -146,7 +147,7 @@ public class ChatGPTChatTab: ChatTab {
}
}
- do {
+ Task { @MainActor in
var lastTitle = ""
observer.observe { [weak self] in
guard let self else { return }
@@ -159,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 6b2252e4..9210a05d 100644
--- a/Core/Sources/ChatGPTChatTab/ChatPanel.swift
+++ b/Core/Sources/ChatGPTChatTab/ChatPanel.swift
@@ -43,7 +43,6 @@ struct ChatPanelMessages: View {
let chat: StoreOf
@State var cancellable = Set()
@State var isScrollToBottomButtonDisplayed = true
- @State var isPinnedToBottom = true
@Namespace var bottomID
@Namespace var topID
@Namespace var scrollSpace
@@ -128,11 +127,7 @@ struct ChatPanelMessages: View {
scrollToBottomButton(proxy: proxy)
}
.background {
- PinToBottomHandler(
- chat: chat,
- isBottomHidden: isBottomHidden,
- pinnedToBottom: $isPinnedToBottom
- ) {
+ PinToBottomHandler(chat: chat, isBottomHidden: isBottomHidden) {
proxy.scrollTo(bottomID, anchor: .bottom)
}
}
@@ -151,22 +146,26 @@ struct ChatPanelMessages: View {
cancellable.forEach { $0.cancel() }
cancellable = []
}
+ .onChange(of: isEnabled) { isEnabled in
+ chat.send(.setIsEnabled(isEnabled))
+ }
}
}
func trackScrollWheel() {
NSApplication.shared.publisher(for: \.currentEvent)
- .filter {
- if !isEnabled { return false }
+ .receive(on: DispatchQueue.main)
+ .filter { [chat] in
+ guard chat.withState(\.isEnabled) else { return false }
return $0?.type == .scrollWheel
}
.compactMap { $0 }
.sink { event in
- guard isPinnedToBottom else { return }
+ guard chat.withState(\.isPinnedToBottom) else { return }
let delta = event.deltaY
let scrollUp = delta > 0
if scrollUp {
- isPinnedToBottom = false
+ chat.send(.manuallyScrolledUp)
}
}
.store(in: &cancellable)
@@ -184,7 +183,7 @@ struct ChatPanelMessages: View {
@ViewBuilder
func scrollToBottomButton(proxy: ScrollViewProxy) -> some View {
Button(action: {
- isPinnedToBottom = true
+ chat.send(.scrollToBottomButtonTapped)
withAnimation(.easeInOut(duration: 0.1)) {
proxy.scrollTo(bottomID, anchor: .bottom)
}
@@ -222,18 +221,16 @@ struct ChatPanelMessages: View {
struct PinToBottomHandler: View {
let chat: StoreOf
let isBottomHidden: Bool
- @Binding var pinnedToBottom: Bool
let scrollToBottom: () -> Void
@State var isInitialLoad = true
-
+
var body: some View {
WithPerceptionTracking {
EmptyView()
.onChange(of: chat.isReceivingMessage) { isReceiving in
if isReceiving {
Task {
- pinnedToBottom = true
await Task.yield()
withAnimation(.easeInOut(duration: 0.1)) {
scrollToBottom()
@@ -242,7 +239,7 @@ struct ChatPanelMessages: View {
}
}
.onChange(of: chat.history.last) { _ in
- if pinnedToBottom || isInitialLoad {
+ if chat.withState(\.isPinnedToBottom) || isInitialLoad {
if isInitialLoad {
isInitialLoad = false
}
@@ -256,7 +253,7 @@ struct ChatPanelMessages: View {
}
.onChange(of: isBottomHidden) { value in
// This is important to prevent it from jumping to the top!
- if value, pinnedToBottom {
+ if value, chat.withState(\.isPinnedToBottom) {
scrollToBottom()
}
}
@@ -286,20 +283,27 @@ struct ChatHistoryItem: View {
var body: some View {
WithPerceptionTracking {
let text = message.text
+ let markdownContent = message.markdownContent
switch message.role {
case .user:
- UserMessage(id: message.id, text: text, chat: chat)
- .listRowInsets(EdgeInsets(
- top: 0,
- leading: -8,
- bottom: 0,
- trailing: -8
- ))
- .padding(.vertical, 4)
+ UserMessage(
+ id: message.id,
+ text: text,
+ markdownContent: markdownContent,
+ chat: chat
+ )
+ .listRowInsets(EdgeInsets(
+ top: 0,
+ leading: -8,
+ bottom: 0,
+ trailing: -8
+ ))
+ .padding(.vertical, 4)
case .assistant:
BotMessage(
id: message.id,
text: text,
+ markdownContent: markdownContent,
references: message.references,
chat: chat
)
@@ -500,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 1683fd88..bcd9a455 100644
--- a/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift
+++ b/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift
@@ -8,6 +8,7 @@ struct BotMessage: View {
var r: Double { messageBubbleCornerRadius }
let id: String
let text: String
+ let markdownContent: MarkdownContent
let references: [DisplayedChatMessage.Reference]
let chat: StoreOf
@Environment(\.colorScheme) var colorScheme
@@ -43,7 +44,7 @@ struct BotMessage: View {
}
}
- ThemedMarkdownText(text)
+ ThemedMarkdownText(markdownContent)
}
.frame(alignment: .trailing)
.padding()
@@ -138,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())
@@ -209,20 +224,22 @@ struct ReferenceIcon: View {
}
#Preview("Bot Message") {
- BotMessage(
- id: "1",
- text: """
+ let text = """
**Hey**! What can I do for you?**Hey**! What can I do for you?**Hey**! What can I do for you?**Hey**! What can I do for you?
```swift
func foo() {}
```
- """,
+ """
+ return BotMessage(
+ id: "1",
+ text: text,
+ markdownContent: .init(text),
references: .init(repeating: .init(
title: "ReferenceList",
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()) })
)
@@ -237,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 eca57a29..2811e4ad 100644
--- a/Core/Sources/ChatGPTChatTab/Views/ThemedMarkdownText.swift
+++ b/Core/Sources/ChatGPTChatTab/Views/ThemedMarkdownText.swift
@@ -12,14 +12,18 @@ struct ThemedMarkdownText: View {
@AppStorage(\.chatCodeFont) var chatCodeFont
@Environment(\.colorScheme) var colorScheme
- let text: String
+ let content: MarkdownContent
init(_ text: String) {
- self.text = text
+ content = .init(text)
+ }
+
+ init(_ content: MarkdownContent) {
+ self.content = content
}
var body: some View {
- Markdown(text)
+ Markdown(content)
.textSelection(.enabled)
.markdownTheme(.custom(
fontSize: chatFontSize,
@@ -67,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/ChatGPTChatTab/Views/UserMessage.swift b/Core/Sources/ChatGPTChatTab/Views/UserMessage.swift
index d8c2af86..edac231a 100644
--- a/Core/Sources/ChatGPTChatTab/Views/UserMessage.swift
+++ b/Core/Sources/ChatGPTChatTab/Views/UserMessage.swift
@@ -7,11 +7,12 @@ struct UserMessage: View {
var r: Double { messageBubbleCornerRadius }
let id: String
let text: String
+ let markdownContent: MarkdownContent
let chat: StoreOf
@Environment(\.colorScheme) var colorScheme
var body: some View {
- ThemedMarkdownText(text)
+ ThemedMarkdownText(markdownContent)
.frame(alignment: .leading)
.padding()
.background {
@@ -50,21 +51,24 @@ struct UserMessage: View {
}
#Preview {
- UserMessage(
+ let text = #"""
+ Please buy me a coffee!
+ | Coffee | Milk |
+ |--------|------|
+ | Espresso | No |
+ | Latte | Yes |
+ ```swift
+ func foo() {}
+ ```
+ ```objectivec
+ - (void)bar {}
+ ```
+ """#
+
+ return UserMessage(
id: "A",
- text: #"""
- Please buy me a coffee!
- | Coffee | Milk |
- |--------|------|
- | Espresso | No |
- | Latte | Yes |
- ```swift
- func foo() {}
- ```
- ```objectivec
- - (void)bar {}
- ```
- """#,
+ text: text,
+ markdownContent: .init(text),
chat: .init(
initialState: .init(history: [] as [DisplayedChatMessage], isReceivingMessage: false),
reducer: { Chat(service: .init()) }
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/ChatFunctionProvider.swift b/Core/Sources/ChatService/ChatFunctionProvider.swift
index dffab8f2..de8ca5bf 100644
--- a/Core/Sources/ChatService/ChatFunctionProvider.swift
+++ b/Core/Sources/ChatService/ChatFunctionProvider.swift
@@ -1,3 +1,4 @@
+import ChatBasic
import Foundation
import OpenAIService
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/ChatService/DynamicContextController.swift b/Core/Sources/ChatService/DynamicContextController.swift
index c6adb9a4..11ae9753 100644
--- a/Core/Sources/ChatService/DynamicContextController.swift
+++ b/Core/Sources/ChatService/DynamicContextController.swift
@@ -60,7 +60,7 @@ final class DynamicContextController {
let idIndexMap = chatModels.enumerated().reduce(into: [String: Int]()) {
$0[$1.element.id] = $1.offset
}
- return ids.sorted(by: {
+ return ids.filter { !$0.isEmpty }.sorted(by: {
let lhs = idIndexMap[$0] ?? Int.max
let rhs = idIndexMap[$1] ?? Int.max
return lhs < rhs
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 408dbd55..00000000
--- a/Core/Sources/HostApp/AccountSettings/BingSearchView.swift
+++ /dev/null
@@ -1,51 +0,0 @@
-import AppKit
-import Client
-import OpenAIService
-import Preferences
-import SuggestionModel
-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 f6e8ee26..e60af2a8 100644
--- a/Core/Sources/HostApp/AccountSettings/CodeiumView.swift
+++ b/Core/Sources/HostApp/AccountSettings/CodeiumView.swift
@@ -14,10 +14,14 @@ struct CodeiumView: View {
@AppStorage(\.codeiumEnterpriseMode) var codeiumEnterpriseMode
@AppStorage(\.codeiumPortalUrl) var codeiumPortalUrl
@AppStorage(\.codeiumApiUrl) var codeiumApiUrl
+ @AppStorage(\.codeiumIndexEnabled) var indexEnabled
init() {
isSignedIn = codeiumAuthService.isSignedIn
- installationStatus = installationManager.checkInstallation()
+ installationStatus = .notInstalled
+ Task { @MainActor in
+ installationStatus = await installationManager.checkInstallation()
+ }
}
init(
@@ -56,7 +60,7 @@ struct CodeiumView: View {
func refreshInstallationStatus() {
Task { @MainActor in
- installationStatus = installationManager.checkInstallation()
+ installationStatus = await installationManager.checkInstallation()
}
}
@@ -147,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
@@ -202,6 +206,12 @@ struct CodeiumView: View {
}
}
}
+
+ SubSection(title: Text("Indexing")) {
+ Form {
+ Toggle("Enable Indexing", isOn: $viewModel.indexEnabled)
+ }
+ }
SubSection(title: Text("Enterprise")) {
Form {
@@ -313,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 44d724ae..ec627113 100644
--- a/Core/Sources/HostApp/AccountSettings/GitHubCopilotView.swift
+++ b/Core/Sources/HostApp/AccountSettings/GitHubCopilotView.swift
@@ -3,7 +3,7 @@ import Client
import GitHubCopilotService
import Preferences
import SharedUIComponents
-import SuggestionModel
+import SuggestionBasic
import SwiftUI
struct GitHubCopilotView: View {
@@ -20,10 +20,12 @@ struct GitHubCopilotView: View {
@AppStorage(\.gitHubCopilotProxyPassword) var gitHubCopilotProxyPassword
@AppStorage(\.gitHubCopilotUseStrictSSL) var gitHubCopilotUseStrictSSL
@AppStorage(\.gitHubCopilotEnterpriseURI) var gitHubCopilotEnterpriseURI
+ @AppStorage(\.gitHubCopilotPretendIDEToBeVSCode) var pretendIDEToBeVSCode
@AppStorage(\.disableGitHubCopilotSettingsAutoRefreshOnAppear)
var disableGitHubCopilotSettingsAutoRefreshOnAppear
@AppStorage(\.gitHubCopilotLoadKeyChainCertificates)
var gitHubCopilotLoadKeyChainCertificates
+ @AppStorage(\.gitHubCopilotModelId) var gitHubCopilotModelId
init() {}
}
@@ -156,7 +158,7 @@ struct GitHubCopilotView: View {
"node"
)
) {
- Text("Path to Node (v18+)")
+ Text("Path to Node (v22.0+)")
}
Text(
@@ -198,7 +200,7 @@ struct GitHubCopilotView: View {
.foregroundColor(.secondary)
.font(.callout)
.dynamicHeightTextInFormWorkaround()
-
+
Toggle(isOn: $settings.gitHubCopilotLoadKeyChainCertificates) {
Text("Load certificates in keychain")
}
@@ -259,19 +261,27 @@ struct GitHubCopilotView: View {
if isRunningAction {
ActivityIndicatorView()
}
- }
+ }
.opacity(isRunningAction ? 0.8 : 1)
.disabled(isRunningAction)
- Button("Refresh Configuration for Enterprise and Proxy") {
+ Button("Refresh configurations") {
refreshConfiguration()
}
+
+ Form {
+ GitHubCopilotModelPicker(
+ title: "Chat Model Name",
+ gitHubCopilotModelId: $settings.gitHubCopilotModelId
+ )
+ }
}
SettingsDivider("Advanced")
Form {
- Toggle("Verbose Log", isOn: $settings.gitHubCopilotVerboseLog)
+ Toggle("Verbose log", isOn: $settings.gitHubCopilotVerboseLog)
+ Toggle("Pretend IDE to be VSCode", isOn: $settings.pretendIDEToBeVSCode)
}
SettingsDivider("Enterprise")
@@ -281,7 +291,7 @@ struct GitHubCopilotView: View {
text: $settings.gitHubCopilotEnterpriseURI,
prompt: Text("Leave it blank if non is available.")
) {
- Text("Auth Provider URL")
+ Text("Auth provider URL")
}
}
@@ -292,18 +302,18 @@ struct GitHubCopilotView: View {
text: $settings.gitHubCopilotProxyHost,
prompt: Text("xxx.xxx.xxx.xxx, leave it blank to disable proxy.")
) {
- Text("Proxy Host")
+ Text("Proxy host")
}
TextField(text: $settings.gitHubCopilotProxyPort, prompt: Text("80")) {
- Text("Proxy Port")
+ Text("Proxy port")
}
TextField(text: $settings.gitHubCopilotProxyUsername) {
- Text("Proxy Username")
+ Text("Proxy username")
}
SecureField(text: $settings.gitHubCopilotProxyPassword) {
- Text("Proxy Password")
+ Text("Proxy password")
}
- Toggle("Proxy Strict SSL", isOn: $settings.gitHubCopilotUseStrictSSL)
+ Toggle("Proxy strict SSL", isOn: $settings.gitHubCopilotUseStrictSSL)
}
}
Spacer()
@@ -347,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/DebugView.swift b/Core/Sources/HostApp/DebugView.swift
index 85c9ed4d..527d5b7b 100644
--- a/Core/Sources/HostApp/DebugView.swift
+++ b/Core/Sources/HostApp/DebugView.swift
@@ -29,6 +29,8 @@ final class DebugSettings: ObservableObject {
var observeToAXNotificationWithDefaultMode
@AppStorage(\.useCloudflareDomainNameForLicenseCheck)
var useCloudflareDomainNameForLicenseCheck
+ @AppStorage(\.doNotInstallLaunchAgentAutomatically)
+ var doNotInstallLaunchAgentAutomatically
init() {}
}
@@ -136,6 +138,12 @@ struct DebugSettingsView: View {
Text("Use Cloudflare domain name for license check")
}
+ Toggle(
+ isOn: $settings.doNotInstallLaunchAgentAutomatically
+ ) {
+ Text("Don't install launch agent automatically")
+ }
+
Button("Reset update cycle") {
updateChecker.resetUpdateCycle()
}
diff --git a/Core/Sources/HostApp/FeatureSettings/Chat/ChatSettingsGeneralSectionView.swift b/Core/Sources/HostApp/FeatureSettings/Chat/ChatSettingsGeneralSectionView.swift
index 36b27f74..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
@@ -16,12 +18,14 @@ struct ChatSettingsGeneralSectionView: View {
@AppStorage(\.chatCodeFont) var chatCodeFont
@AppStorage(\.defaultChatFeatureChatModelId) var defaultChatFeatureChatModelId
+ @AppStorage(\.preferredChatModelIdForUtilities) var utilityChatModelId
@AppStorage(\.defaultChatSystemPrompt) var defaultChatSystemPrompt
@AppStorage(\.chatSearchPluginMaxIterations) var chatSearchPluginMaxIterations
@AppStorage(\.defaultChatFeatureEmbeddingModelId) var defaultChatFeatureEmbeddingModelId
@AppStorage(\.chatModels) var chatModels
@AppStorage(\.embeddingModels) var embeddingModels
@AppStorage(\.wrapCodeInChatCodeBlock) var wrapCodeInCodeBlock
+ @AppStorage(\.chatPanelFloatOnTopOption) var chatPanelFloatOnTopOption
@AppStorage(
\.keepFloatOnTopIfChatPanelAndXcodeOverlaps
) var keepFloatOnTopIfChatPanelAndXcodeOverlaps
@@ -32,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
@@ -57,22 +105,30 @@ 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 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,
+ text: $settings.openChatInBrowserURL,
prompt: Text("https://")
)
.textFieldStyle(.roundedBorder)
@@ -98,14 +154,39 @@ struct ChatSettingsGeneralSectionView: View {
"Chat model",
selection: $settings.defaultChatFeatureChatModelId
) {
- if !settings.chatModels
- .contains(where: { $0.id == settings.defaultChatFeatureChatModelId })
+ let allModels = settings.chatModels + [.init(
+ id: "com.github.copilot",
+ name: "GitHub Copilot Language Server",
+ format: .openAI,
+ info: .init()
+ )]
+
+ if !allModels.contains(where: { $0.id == settings.defaultChatFeatureChatModelId }) {
+ Text(
+ (allModels.first?.name).map { "\($0) (Default)" } ?? "No model found"
+ )
+ .tag(settings.defaultChatFeatureChatModelId)
+ }
+
+ ForEach(allModels, id: \.id) { chatModel in
+ Text(chatModel.name).tag(chatModel.id)
+ }
+ }
+
+ Picker(
+ "Utility chat model",
+ selection: $settings.utilityChatModelId
+ ) {
+ Text("Use the default model").tag("")
+
+ if !settings.chatModels.contains(where: { $0.id == settings.utilityChatModelId }),
+ !settings.utilityChatModelId.isEmpty
{
Text(
(settings.chatModels.first?.name).map { "\($0) (Default)" }
- ?? "No model found"
+ ?? "No Model Found"
)
- .tag(settings.defaultChatFeatureChatModelId)
+ .tag(settings.utilityChatModelId)
}
ForEach(settings.chatModels, id: \.id) { chatModel in
@@ -172,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)
}
@@ -207,19 +297,26 @@ struct ChatSettingsGeneralSectionView: View {
Text("Wrap text in code block")
}
- #if canImport(ProHostApp)
-
CodeHighlightThemePicker(scenario: .chat)
- #endif
+ 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 d2d5a862..00000000
--- a/Core/Sources/HostApp/FeatureSettings/Chat/ChatSettingsScopeSectionView.swift
+++ /dev/null
@@ -1,220 +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")
- }
-
- Picker(
- "Preferred chat model",
- selection: $settings.preferredChatModelIdForSenseScope
- ) {
- Text("Use the default model").tag("")
-
- if !settings.chatModels
- .contains(where: {
- $0.id == settings.preferredChatModelIdForSenseScope
- }),
- !settings.preferredChatModelIdForSenseScope.isEmpty
- {
- Text(
- (settings.chatModels.first?.name).map { "\($0) (Default)" }
- ?? "No model found"
- )
- .tag(settings.preferredChatModelIdForSenseScope)
- }
-
- ForEach(settings.chatModels, 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")
- }
-
- Picker(
- "Preferred chat model",
- selection: $settings.preferredChatModelIdForProjectScope
- ) {
- Text("Use the default model").tag("")
-
- if !settings.chatModels
- .contains(where: {
- $0.id == settings.preferredChatModelIdForProjectScope
- }),
- !settings.preferredChatModelIdForProjectScope.isEmpty
- {
- Text(
- (settings.chatModels.first?.name).map { "\($0) (Default)" }
- ?? "No Model Found"
- )
- .tag(settings.preferredChatModelIdForProjectScope)
- }
-
- ForEach(settings.chatModels, 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.")
- }
-
- Picker(
- "Preferred chat model",
- selection: $settings.preferredChatModelIdForWebScope
- ) {
- Text("Use the default model").tag("")
-
- if !settings.chatModels
- .contains(where: {
- $0.id == settings.preferredChatModelIdForWebScope
- }),
- !settings.preferredChatModelIdForWebScope.isEmpty
- {
- Text(
- (settings.chatModels.first?.name).map { "\($0) (Default)" }
- ?? "No model found"
- )
- .tag(settings.preferredChatModelIdForWebScope)
- }
-
- ForEach(settings.chatModels, 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 bb35f475..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,70 +56,17 @@ struct PromptToCodeSettingsView: View {
Toggle(isOn: $settings.hideCommonPrecedingSpaces) {
Text("Hide common preceding spaces")
}
-
+
Toggle(isOn: $settings.wrapCode) {
Text("Wrap code")
}
- #if canImport(ProHostApp)
-
CodeHighlightThemePicker(scenario: .promptToCode)
- #endif
-
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 60bb661b..6d894cfd 100644
--- a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureDisabledLanguageListView.swift
+++ b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureDisabledLanguageListView.swift
@@ -1,4 +1,4 @@
-import SuggestionModel
+import SuggestionBasic
import SwiftUI
import SharedUIComponents
@@ -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 2b7a6fef..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)
}
}
}
@@ -97,6 +95,7 @@ struct SuggestionSettingsGeneralSectionView: View {
@StateObject var settings = Settings()
@State var isSuggestionFeatureEnabledListPickerOpen = false
@State var isSuggestionFeatureDisabledLanguageListViewOpen = false
+ @State var isTabToAcceptSuggestionModifierViewOpen = false
var body: some View {
Form {
@@ -171,17 +170,24 @@ struct SuggestionSettingsGeneralSectionView: View {
Text("Real-time suggestion")
}
- #if canImport(ProHostApp)
- WithFeatureEnabled(\.tabToAcceptSuggestion) {
- Toggle(isOn: $settings.acceptSuggestionWithTab) {
+ Toggle(isOn: $settings.acceptSuggestionWithTab) {
+ HStack {
Text("Accept suggestion with Tab")
+
+ Button(action: {
+ isTabToAcceptSuggestionModifierViewOpen = true
+ }) {
+ Image(systemName: "gearshape.fill")
+ }
+ .buttonStyle(.plain)
}
+ }.sheet(isPresented: $isTabToAcceptSuggestionModifierViewOpen) {
+ TabToAcceptSuggestionModifierView()
}
Toggle(isOn: $settings.dismissSuggestionWithEsc) {
Text("Dismiss suggestion with ESC")
}
- #endif
HStack {
Toggle(isOn: $settings.disableSuggestionFeatureGlobally) {
@@ -237,17 +243,77 @@ struct SuggestionSettingsGeneralSectionView: View {
Text("Hide common preceding spaces")
}
- #if canImport(ProHostApp)
-
CodeHighlightThemePicker(scenario: .suggestion)
- #endif
-
FontPicker(font: $settings.font) {
Text("Font")
}
}
}
+
+ struct TabToAcceptSuggestionModifierView: View {
+ final class Settings: ObservableObject {
+ @AppStorage(\.acceptSuggestionWithModifierCommand)
+ var needCommand
+ @AppStorage(\.acceptSuggestionWithModifierOption)
+ var needOption
+ @AppStorage(\.acceptSuggestionWithModifierShift)
+ var needShift
+ @AppStorage(\.acceptSuggestionWithModifierControl)
+ var needControl
+ @AppStorage(\.acceptSuggestionWithModifierOnlyForSwift)
+ var onlyForSwift
+ @AppStorage(\.acceptSuggestionLineWithModifierControl)
+ var acceptLineWithControl
+ }
+
+ @StateObject var settings = Settings()
+ @Environment(\.dismiss) var dismiss
+
+ var body: some View {
+ VStack(spacing: 0) {
+ Form {
+ Text("Accept suggestion with modifier")
+ .font(.headline)
+ HStack {
+ Toggle(isOn: $settings.needCommand) {
+ Text("Command")
+ }
+ Toggle(isOn: $settings.needOption) {
+ Text("Option")
+ }
+ Toggle(isOn: $settings.needShift) {
+ Text("Shift")
+ }
+ Toggle(isOn: $settings.needControl) {
+ Text("Control")
+ }
+ }
+ Toggle(isOn: $settings.onlyForSwift) {
+ Text("Only require modifiers for Swift")
+ }
+
+ Divider()
+
+ Toggle(isOn: $settings.acceptLineWithControl) {
+ Text("Accept suggestion first line with Control")
+ }
+ }
+ .padding()
+
+ Divider()
+
+ HStack {
+ Spacer()
+ Button(action: { dismiss() }) {
+ Text("Done")
+ }
+ .keyboardShortcut(.defaultAction)
+ }
+ .padding()
+ }
+ }
+ }
}
#Preview {
@@ -255,3 +321,7 @@ struct SuggestionSettingsGeneralSectionView: View {
.padding()
}
+#Preview {
+ SuggestionSettingsGeneralSectionView.TabToAcceptSuggestionModifierView()
+}
+
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 b50cd876..96ade16c 100644
--- a/Core/Sources/HostApp/General.swift
+++ b/Core/Sources/HostApp/General.swift
@@ -12,46 +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
- await send(.setupLaunchAgentIfNeeded)
+ if Self.bundleIsInApplicationsFolder {
+ return .run { send in
+ 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()
@@ -104,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
new file mode 100644
index 00000000..1c7151af
--- /dev/null
+++ b/Core/Sources/HostApp/SharedComponents/CodeHighlightThemePicker.swift
@@ -0,0 +1,71 @@
+import Foundation
+import Preferences
+import SwiftUI
+
+public struct CodeHighlightThemePicker: View {
+ public enum Scenario {
+ case suggestion
+ case promptToCode
+ case chat
+ }
+
+ let scenario: Scenario
+
+ public init(scenario: Scenario) {
+ self.scenario = scenario
+ }
+
+ public var body: some View {
+ switch scenario {
+ case .suggestion:
+ SuggestionThemePicker()
+ case .promptToCode:
+ PromptToCodeThemePicker()
+ case .chat:
+ ChatThemePicker()
+ }
+ }
+
+ struct SuggestionThemePicker: View {
+ @AppStorage(\.syncSuggestionHighlightTheme) var sync: Bool
+ var body: some View {
+ SyncToggle(sync: $sync)
+ }
+ }
+
+ struct PromptToCodeThemePicker: View {
+ @AppStorage(\.syncPromptToCodeHighlightTheme) var sync: Bool
+ var body: some View {
+ SyncToggle(sync: $sync)
+ }
+ }
+
+ struct ChatThemePicker: View {
+ @AppStorage(\.syncChatCodeHighlightTheme) var sync: Bool
+ var body: some View {
+ SyncToggle(sync: $sync)
+ }
+ }
+
+ struct SyncToggle: View {
+ @Binding var sync: Bool
+
+ var body: some View {
+ VStack(alignment: .leading) {
+ Toggle(isOn: $sync) {
+ Text("Sync color scheme with Xcode")
+ }
+
+ Text("To refresh the theme, you must activate the extension service app once.")
+ .font(.footnote)
+ .foregroundColor(.secondary)
+ }
+ }
+ }
+}
+
+#Preview {
+ 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..?
+ private var isObserving: Bool { CGEventObservationTask != nil }
+ private let userDefaultsObserver = UserDefaultsObserver(
+ object: UserDefaults.shared, forKeyPaths: [
+ UserDefaultPreferenceKeys().acceptSuggestionWithTab.key,
+ UserDefaultPreferenceKeys().dismissSuggestionWithEsc.key,
+ ], context: nil
+ )
+ private var stoppedForExit = false
+
+ struct ObservationKey: Hashable {}
+
+ var canTapToAcceptSuggestion: Bool {
+ UserDefaults.shared.value(for: \.acceptSuggestionWithTab)
+ }
+
+ var canEscToDismissSuggestion: Bool {
+ UserDefaults.shared.value(for: \.dismissSuggestionWithEsc)
+ }
+
+ @MainActor
+ func stopForExit() {
+ stoppedForExit = true
+ stopObservation()
+ }
+
+ init() {
+ _ = ThreadSafeAccessToXcodeInspector.shared
+
+ hook.add(
+ .init(
+ eventsOfInterest: [.keyDown],
+ convert: { [weak self] _, _, event in
+ self?.handleEvent(event) ?? .unchanged
+ }
+ ),
+ forKey: ObservationKey()
+ )
+ }
+
+ func start() {
+ Task { [weak self] in
+ for await _ in ActiveApplicationMonitor.shared.createInfoStream() {
+ guard let self else { return }
+ try Task.checkCancellation()
+ Task { @MainActor in
+ if ActiveApplicationMonitor.shared.activeXcode != nil {
+ self.startObservation()
+ } else {
+ self.stopObservation()
+ }
+ }
+ }
+ }
+
+ userDefaultsObserver.onChange = { [weak self] in
+ guard let self else { return }
+ Task { @MainActor in
+ if self.canTapToAcceptSuggestion || self.canEscToDismissSuggestion {
+ self.startObservation()
+ } else {
+ self.stopObservation()
+ }
+ }
+ }
+ }
+
+ @MainActor
+ func startObservation() {
+ guard !stoppedForExit else { return }
+ guard canTapToAcceptSuggestion || canEscToDismissSuggestion else { return }
+ hook.activateIfPossible()
+ }
+
+ @MainActor
+ func stopObservation() {
+ hook.deactivate()
+ }
+
+ func handleEvent(_ event: CGEvent) -> CGEventManipulation.Result {
+ let keycode = Int(event.getIntegerValueField(.keyboardEventKeycode))
+ let tab = 48
+ let esc = 53
+
+ switch keycode {
+ case tab:
+ return handleTab(event.flags)
+ case esc:
+ return handleEsc(event.flags)
+ default:
+ return .unchanged
+ }
+ }
+
+ func handleTab(_ flags: CGEventFlags) -> CGEventManipulation.Result {
+ Logger.service.info("TabToAcceptSuggestion: Tab")
+
+ guard let fileURL = ThreadSafeAccessToXcodeInspector.shared.activeDocumentURL
+ else {
+ Logger.service.info("TabToAcceptSuggestion: No active document")
+ return .unchanged
+ }
+
+ let language = languageIdentifierFromFileURL(fileURL)
+
+ if flags.contains(.maskHelp) { return .unchanged }
+
+ let requiredFlagsToTrigger: CGEventFlags = {
+ var all = CGEventFlags()
+ if UserDefaults.shared.value(for: \.acceptSuggestionWithModifierShift) {
+ all.insert(.maskShift)
+ }
+ if UserDefaults.shared.value(for: \.acceptSuggestionWithModifierControl) {
+ all.insert(.maskControl)
+ }
+ if UserDefaults.shared.value(for: \.acceptSuggestionWithModifierOption) {
+ all.insert(.maskAlternate)
+ }
+ if UserDefaults.shared.value(for: \.acceptSuggestionWithModifierCommand) {
+ all.insert(.maskCommand)
+ }
+ if UserDefaults.shared.value(for: \.acceptSuggestionWithModifierOnlyForSwift) {
+ if language == .builtIn(.swift) {
+ return all
+ } else {
+ return []
+ }
+ } else {
+ return all
+ }
+ }()
+
+ 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
+ }
+ }
+
+ guard canTapToAcceptSuggestion else {
+ Logger.service.info("TabToAcceptSuggestion: Feature not available")
+ return .unchanged
+ }
+
+ 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 {
+ Logger.service.info("TabToAcceptSuggestion: Accept")
+ if flags.contains(.maskControl),
+ !requiredFlagsToTrigger.contains(.maskControl)
+ {
+ Task { await commandHandler.acceptActiveSuggestionLineInGroup(atIndex: nil)
+ }
+ } else {
+ Task { await commandHandler.acceptSuggestion() }
+ }
+ return .discarded
+ } 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 {
+ static func checkIfAcceptSuggestion(
+ lines: [String],
+ cursorPosition: CursorPosition,
+ codeMetadata: FilespaceCodeMetadata,
+ presentingSuggestionText: String
+ ) -> Bool {
+ let line = cursorPosition.line
+ guard line >= 0, line < lines.endIndex else {
+ return true
+ }
+ let col = cursorPosition.character
+ let prefixEndIndex = lines[line].utf16.index(
+ lines[line].utf16.startIndex,
+ offsetBy: col,
+ limitedBy: lines[line].utf16.endIndex
+ ) ?? lines[line].utf16.endIndex
+ let prefix = String(lines[line][..
-
-
-
- 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 6a009aee..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 SuggestionModel
+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 cff916f3..ac0fd4df 100644
--- a/Core/Sources/PromptToCodeService/PreviewPromptToCodeService.swift
+++ b/Core/Sources/PromptToCodeService/PreviewPromptToCodeService.swift
@@ -1,10 +1,47 @@
import Foundation
-import SuggestionModel
+import ModificationBasic
+import SuggestionBasic
+
+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 final class PreviewPromptToCodeService: PromptToCodeServiceType {
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 25d3cc6a..3e0cd400 100644
--- a/Core/Sources/PromptToCodeService/PromptToCodeServiceType.swift
+++ b/Core/Sources/PromptToCodeService/PromptToCodeServiceType.swift
@@ -1,19 +1,6 @@
import Dependencies
import Foundation
-import SuggestionModel
-
-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()
-}
+import SuggestionBasic
public struct PromptToCodeSource {
public var language: CodeLanguage
@@ -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/DependencyUpdater.swift b/Core/Sources/Service/DependencyUpdater.swift
index 31ab8b7f..6eb4124a 100644
--- a/Core/Sources/Service/DependencyUpdater.swift
+++ b/Core/Sources/Service/DependencyUpdater.swift
@@ -1,4 +1,5 @@
import CodeiumService
+import Foundation
import GitHubCopilotService
import Logger
@@ -39,8 +40,10 @@ struct DependencyUpdater {
}
}
}
+
let codeium = CodeiumInstallationManager()
- switch codeium.checkInstallation() {
+
+ switch await codeium.checkInstallation() {
case .notInstalled: break
case .installed: break
case .unsupported: break
diff --git a/Core/Sources/Service/GUI/ChatTabFactory.swift b/Core/Sources/Service/GUI/ChatTabFactory.swift
index 18af0372..fae16330 100644
--- a/Core/Sources/Service/GUI/ChatTabFactory.swift
+++ b/Core/Sources/Service/GUI/ChatTabFactory.swift
@@ -1,136 +1,57 @@
+import BuiltinExtension
import ChatGPTChatTab
import ChatService
import ChatTab
import Foundation
import PromptToCodeService
-import SuggestionModel
+import SuggestionBasic
import SuggestionWidget
import XcodeInspector
-#if canImport(ProChatTabs)
-import ProChatTabs
-
enum ChatTabFactory {
static func chatTabBuilderCollection() -> [ChatTabBuilderCollection] {
- func folderIfNeeded(
- _ builders: [any ChatTabBuilder],
- title: String
- ) -> ChatTabBuilderCollection? {
- if builders.count > 1 {
- return .folder(title: title, kinds: builders.map(ChatTabKind.init))
- }
- if let first = builders.first { return .kind(ChatTabKind(first)) }
- return nil
+ 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
}
-
- let collection = [
- folderIfNeeded(ChatGPTChatTab.chatBuilders(), title: ChatGPTChatTab.name),
- folderIfNeeded(
- BrowserChatTab.chatBuilders(
- externalDependency: externalDependenciesForBrowserChatTab()
- ),
- title: BrowserChatTab.name
- ),
- folderIfNeeded(TerminalChatTab.chatBuilders(), title: TerminalChatTab.name),
- ].compactMap { $0 }
-
- return collection
}
- static func externalDependenciesForBrowserChatTab() -> BrowserChatTab.ExternalDependency {
- .init(
- getEditorContent: {
- guard let editor = XcodeInspector.shared.focusedEditor else {
- return .init(selectedText: "", language: "", fileContent: "")
- }
- let content = editor.getContent()
- return .init(
- selectedText: content.selectedContent,
- language: (
- XcodeInspector.shared.activeDocumentURL
- .map(languageIdentifierFromFileURL) ?? .plaintext
- ).rawValue,
- fileContent: content.content
- )
- },
- 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
- }
- }
- )
+ private static func folderIfNeeded(
+ _ builders: [any ChatTabBuilder],
+ title: String
+ ) -> ChatTabBuilderCollection? {
+ if builders.count > 1 {
+ return .folder(title: title, kinds: builders.map(ChatTabKind.init))
+ }
+ if let first = builders.first { return .kind(ChatTabKind(first)) }
+ return nil
}
-}
-#else
-
-enum ChatTabFactory {
- static func chatTabBuilderCollection() -> [ChatTabBuilderCollection] {
- func folderIfNeeded(
- _ builders: [any ChatTabBuilder],
- title: String
- ) -> ChatTabBuilderCollection? {
- if builders.count > 1 {
- return .folder(title: title, kinds: builders.map(ChatTabKind.init))
+ static func chatTabsFromExtensions()
+ -> (default: ChatTabBuilderCollection?, others: [ChatTabBuilderCollection])
+ {
+ let extensions = BuiltinExtensionManager.shared.extensions
+ let chatTabTypes = extensions.flatMap(\.chatTabTypes)
+ 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)
}
- if let first = builders.first { return .kind(ChatTabKind(first)) }
- return nil
}
-
- return [
- folderIfNeeded(ChatGPTChatTab.chatBuilders(), title: ChatGPTChatTab.name),
- ].compactMap { $0 }
+ return (defaultChatTab, otherChatTabs)
}
}
-
-#endif
-
diff --git a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift
index 58bbb1b5..dfbd719a 100644
--- a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift
+++ b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift
@@ -1,18 +1,16 @@
import ActiveApplicationMonitor
import AppActivator
import AppKit
+import BuiltinExtension
import ChatGPTChatTab
import ChatTab
import ComposableArchitecture
import Dependencies
+import Logger
import Preferences
-import SuggestionModel
+import SuggestionBasic
import SuggestionWidget
-#if canImport(ProChatTabs)
-import ProChatTabs
-#endif
-
#if canImport(ChatTabPersistent)
import ChatTabPersistent
#endif
@@ -20,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 }
}
@@ -54,13 +52,16 @@ struct GUI {
enum Action {
case start
- case openChatPanel(forceDetach: Bool)
+ case openChatPanel(forceDetach: Bool, activateThisApp: Bool)
case createAndSwitchToChatGPTChatTabIfNeeded
- case createAndSwitchToBrowserTabIfNeeded(url: URL)
+ case createAndSwitchToChatTabIfNeededMatching(
+ check: (any ChatTab) -> Bool,
+ kind: ChatTabKind?
+ )
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))))
@@ -81,7 +82,7 @@ struct GUI {
var body: some ReducerOf {
CombineReducers {
Scope(state: \.suggestionWidgetState, action: \.suggestionWidget) {
- WidgetFeature()
+ Widget()
}
Scope(
@@ -102,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)
@@ -134,7 +135,7 @@ struct GUI {
return .none
#endif
- case let .openChatPanel(forceDetach):
+ case let .openChatPanel(forceDetach, activate):
return .run { send in
await send(
.suggestionWidget(
@@ -143,55 +144,33 @@ struct GUI {
)
await send(.suggestionWidget(.updateKeyWindow(.chatPanel)))
- activateThisApp()
+ if activate {
+ activateThisApp()
+ }
}
case .createAndSwitchToChatGPTChatTabIfNeeded:
- if let selectedTabInfo = state.chatTabGroup.selectedTabInfo,
- chatTabPool.getTab(of: selectedTabInfo.id) is ChatGPTChatTab
- {
- // Already in ChatGPT tab
- return .none
- }
-
- if let firstChatGPTTabInfo = state.chatTabGroup.tabInfo.first(where: {
- chatTabPool.getTab(of: $0.id) is ChatGPTChatTab
- }) {
- return .run { send in
- await send(.suggestionWidget(.chatPanel(.tabClicked(
- id: firstChatGPTTabInfo.id
- ))))
- }
- }
return .run { send in
- if let (_, chatTabInfo) = await chatTabPool.createTab(for: nil) {
- await send(
- .suggestionWidget(.chatPanel(.appendAndSelectTab(chatTabInfo)))
- )
- }
- }
-
- case let .createAndSwitchToBrowserTabIfNeeded(url):
- #if canImport(BrowserChatTab)
- func match(_ tabURL: URL?) -> Bool {
- guard let tabURL else { return false }
- return tabURL == url
- || tabURL.absoluteString.hasPrefix(url.absoluteString)
+ await send(.createAndSwitchToChatTabIfNeededMatching(
+ check: { $0 is ChatGPTChatTab },
+ kind: nil
+ ))
}
+ case let .createAndSwitchToChatTabIfNeededMatching(check, kind):
if let selectedTabInfo = state.chatTabGroup.selectedTabInfo,
- let tab = chatTabPool.getTab(of: selectedTabInfo.id) as? BrowserChatTab,
- match(tab.url)
+ let tab = chatTabPool.getTab(of: selectedTabInfo.id),
+ check(tab)
{
- // Already in the target Browser tab
+ // Already in ChatGPT tab
return .none
}
if let firstChatGPTTabInfo = state.chatTabGroup.tabInfo.first(where: {
- guard let tab = chatTabPool.getTab(of: $0.id) as? BrowserChatTab,
- match(tab.url)
- else { return false }
- return true
+ if let tab = chatTabPool.getTab(of: $0.id) {
+ return check(tab)
+ }
+ return false
}) {
return .run { send in
await send(.suggestionWidget(.chatPanel(.tabClicked(
@@ -199,64 +178,44 @@ struct GUI {
))))
}
}
-
return .run { send in
- if let (_, chatTabInfo) = await chatTabPool.createTab(
- for: .init(BrowserChatTab.urlChatBuilder(
- url: url,
- externalDependency: ChatTabFactory
- .externalDependenciesForBrowserChatTab()
- ))
- ) {
+ if let (_, chatTabInfo) = await chatTabPool.createTab(for: kind) {
await send(
.suggestionWidget(.chatPanel(.appendAndSelectTab(chatTabInfo)))
)
}
}
- #else
- return .none
- #endif
-
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:
@@ -264,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
@@ -329,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 = {
@@ -371,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
@@ -409,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)
}
@@ -419,56 +376,45 @@ extension ChatTabPool {
) async -> (any ChatTab, ChatTabInfo)? {
let id = UUID().uuidString
let info = ChatTabInfo(id: id, title: "")
- guard let builder = kind?.builder else {
- let chatTap = ChatGPTChatTab(store: createStore(id))
- setTab(chatTap)
- return (chatTap, info)
- }
-
+ 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,
- externalDependency: ()
- ) else { break }
- return await createTab(id: data.id, from: builder)
- case EmptyChatTab.name:
- guard let builder = try? await EmptyChatTab.restore(
- from: data.data,
- externalDependency: ()
- ) else { break }
- return await createTab(id: data.id, from: builder)
- case BrowserChatTab.name:
- guard let builder = try? BrowserChatTab.restore(
- from: data.data,
- externalDependency: ChatTabFactory.externalDependenciesForBrowserChatTab()
- ) else { break }
- return await createTab(id: data.id, from: builder)
- case TerminalChatTab.name:
- guard let builder = try? await TerminalChatTab.restore(
- from: data.data,
- externalDependency: ()
- ) else { break }
+ guard let builder = try? await ChatGPTChatTab.restore(from: data.data)
+ else { fallthrough }
return await createTab(id: data.id, from: builder)
default:
- break
+ let chatTabTypes = BuiltinExtensionManager.shared.extensions.flatMap(\.chatTabTypes)
+ for type in chatTabTypes {
+ if type.name == data.name {
+ 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
+ }
+ }
+ }
}
- guard let builder = try? await EmptyChatTab.restore(
- from: data.data, externalDependency: ()
- ) else {
- return nil
- }
+ guard let builder = try? await EmptyChatTab.restore(from: data.data) else { return nil }
return await createTab(id: data.id, from: builder)
}
#endif
diff --git a/Core/Sources/Service/GUI/WidgetDataSource.swift b/Core/Sources/Service/GUI/WidgetDataSource.swift
index 6233a6e5..ae1b6371 100644
--- a/Core/Sources/Service/GUI/WidgetDataSource.swift
+++ b/Core/Sources/Service/GUI/WidgetDataSource.swift
@@ -7,14 +7,14 @@ import Foundation
import GitHubCopilotService
import OpenAIService
import PromptToCodeService
-import SuggestionModel
+import SuggestionBasic
import SuggestionWidget
@MainActor
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 04b8bd9c..b1702924 100644
--- a/Core/Sources/Service/Service.swift
+++ b/Core/Sources/Service/Service.swift
@@ -1,17 +1,20 @@
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
import WorkspaceSuggestionService
import XcodeInspector
+import XcodeThemeController
import XPCShared
-
#if canImport(ProService)
import ProService
#endif
@@ -23,15 +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
@@ -40,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() }
}
@@ -60,19 +84,6 @@ public final class Service {
workspacePool.registerPlugin {
BuiltinExtensionWorkspacePlugin(workspace: $0)
}
- self.workspacePool = workspacePool
- globalShortcutManager = .init(guiController: guiController)
-
- #if canImport(ProService)
- proService = ProService(
- acceptSuggestion: {
- Task { await PseudoCommandHandler().acceptSuggestion() }
- },
- dismissSuggestion: {
- Task { await PseudoCommandHandler().dismissSuggestion() }
- }
- )
- #endif
scheduledCleaner.service = self
}
@@ -82,29 +93,36 @@ public final class Service {
scheduledCleaner.start()
realtimeSuggestionController.start()
guiController.start()
+ xcodeThemeController.start()
#if canImport(ProService)
proService.start()
#endif
+ overlayWindowController.start()
DependencyUpdater().update()
globalShortcutManager.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)
+ keyBindingManager.start()
+
+ 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)
+ }
+ }
}
}
@MainActor
public func prepareForExit() async {
Logger.service.info("Prepare for exit.")
+ keyBindingManager.stopForExit()
#if canImport(ProService)
proService.prepareForExit()
#endif
@@ -120,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 66f6b2eb..c1d38d78 100644
--- a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift
+++ b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift
@@ -1,20 +1,33 @@
import ActiveApplicationMonitor
import AppKit
+import BuiltinExtension
+import CodeiumService
+import CommandHandler
+import ComposableArchitecture
+import enum CopilotForXcodeKit.SuggestionServiceError
import Dependencies
+import Logger
+import ModificationBasic
import PlusFeatureFlag
import Preferences
+import PromptToCodeCustomization
+import SuggestionBasic
import SuggestionInjector
-import SuggestionModel
+import Terminal
import Toast
import Workspace
import WorkspaceSuggestionService
import XcodeInspector
import XPCShared
+#if canImport(BrowserChatTab)
+import BrowserChatTab
+#endif
+
/// 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 }
@@ -79,7 +92,7 @@ struct PseudoCommandHandler {
}
let snapshot = FilespaceSuggestionSnapshot(
- linesHash: editor.lines.hashValue,
+ lines: editor.lines,
cursorPosition: editor.cursorPosition
)
@@ -102,7 +115,16 @@ struct PseudoCommandHandler {
} else {
presenter.discardSuggestion(fileURL: fileURL)
}
+ } catch let error as SuggestionServiceError {
+ switch error {
+ case let .notice(error):
+ presenter.presentErrorMessage(error.localizedDescription)
+ case .silent:
+ Logger.service.error(error.localizedDescription)
+ return
+ }
} catch {
+ Logger.service.error(error.localizedDescription)
return
}
}
@@ -165,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()
@@ -183,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()
@@ -200,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
@@ -226,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: "",
@@ -251,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
@@ -262,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
@@ -308,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)
@@ -342,17 +461,128 @@ struct PseudoCommandHandler {
}
if openInApp {
- let store = Service.shared.guiController.store
+ #if canImport(BrowserChatTab)
Task { @MainActor in
- await store.send(.createAndSwitchToBrowserTabIfNeeded(url: url)).finish()
- store.send(.openChatPanel(forceDetach: forceDetach))
+ let store = Service.shared.guiController.store
+ await store.send(.createAndSwitchToChatTabIfNeededMatching(
+ check: {
+ func match(_ tabURL: URL?) -> Bool {
+ guard let tabURL else { return false }
+ return tabURL == url
+ || tabURL.absoluteString.hasPrefix(url.absoluteString)
+ }
+
+ guard let tab = $0 as? BrowserChatTab,
+ match(tab.url) else { return false }
+ return true
+ },
+ kind: .init(BrowserChatTab.urlChatBuilder(url: url))
+ )).finish()
+ store.send(.openChatPanel(
+ forceDetach: forceDetach,
+ activateThisApp: activateThisApp
+ ))
}
+ #endif
} else {
Task {
- @Dependency(\.openURL) var openURL
- await openURL(url)
+ NSWorkspace.shared.open(url)
}
}
+ 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.name == id },
+ kind: .init(tab.defaultChatBuilder())
+ )
+ ).finish()
+ store.send(.openChatPanel(
+ forceDetach: forceDetach,
+ activateThisApp: activateThisApp
+ ))
+ }
+ case let .externalExtension(extensionIdentifier, id, _):
+ guard let ext = BuiltinExtensionManager.shared.extensions
+ .first(where: { $0.extensionIdentifier == "plus" }),
+ let tab = ext.chatTabTypes
+ .first(where: { $0.name == "\(extensionIdentifier).\(id)" })
+ else { return }
+ Task { @MainActor in
+ let store = Service.shared.guiController.store
+ await store.send(
+ .createAndSwitchToChatTabIfNeededMatching(
+ check: { $0.name == "\(extensionIdentifier).\(id)" },
+ kind: .init(tab.defaultChatBuilder())
+ )
+ ).finish()
+ store.send(.openChatPanel(
+ 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)
}
}
}
@@ -380,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(
@@ -439,7 +669,7 @@ extension PseudoCommandHandler {
}
func getFileURL() async -> URL? {
- await XcodeInspector.shared.safe.realtimeActiveDocumentURL
+ XcodeInspector.shared.realtimeActiveDocumentURL
}
@WorkspaceActor
@@ -457,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 }
@@ -480,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 4c07b5bb..bc2742c9 100644
--- a/Core/Sources/Service/SuggestionCommandHandler/SuggestionCommandHandler.swift
+++ b/Core/Sources/Service/SuggestionCommandHandler/SuggestionCommandHandler.swift
@@ -1,4 +1,4 @@
-import SuggestionModel
+import SuggestionBasic
import XPCShared
protocol SuggestionCommandHandler {
@@ -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 d493dded..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 SuggestionBasic
import SuggestionInjector
-import SuggestionModel
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 e2568f9f..7069422b 100644
--- a/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift
+++ b/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift
@@ -1,7 +1,7 @@
import ChatService
import Foundation
import OpenAIService
-import SuggestionModel
+import SuggestionBasic
import SuggestionWidget
struct PresentInWindowSuggestionPresenter {
@@ -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 5fdd6ccc..335f0c83 100644
--- a/Core/Sources/SuggestionService/SuggestionService.swift
+++ b/Core/Sources/SuggestionService/SuggestionService.swift
@@ -1,10 +1,11 @@
import BuiltinExtension
import CodeiumService
+import enum CopilotForXcodeKit.SuggestionServiceError
import struct CopilotForXcodeKit.WorkspaceInfo
import Foundation
import GitHubCopilotService
import Preferences
-import SuggestionModel
+import SuggestionBasic
import SuggestionProvider
import UserDefaultsObserver
import Workspace
@@ -16,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(
@@ -62,36 +67,44 @@ public extension SuggestionService {
func getSuggestions(
_ request: SuggestionRequest,
workspaceInfo: CopilotForXcodeKit.WorkspaceInfo
- ) async throws -> [SuggestionModel.CodeSuggestion] {
- var getSuggestion = suggestionProvider.getSuggestions(_:workspaceInfo:)
- let configuration = await configuration
+ ) async throws -> [SuggestionBasic.CodeSuggestion] {
+ do {
+ var getSuggestion = suggestionProvider.getSuggestions(_:workspaceInfo:)
+ let configuration = await configuration
- for middleware in middlewares.reversed() {
- getSuggestion = { [getSuggestion] request, workspaceInfo in
- try await middleware.getSuggestion(
- request,
- configuration: configuration,
- next: { [getSuggestion] request in
- try await getSuggestion(request, workspaceInfo)
- }
- )
+ for middleware in middlewares.reversed() {
+ getSuggestion = { [getSuggestion] request, workspaceInfo in
+ try await middleware.getSuggestion(
+ request,
+ configuration: configuration,
+ next: { [getSuggestion] request in
+ try await getSuggestion(request, workspaceInfo)
+ }
+ )
+ }
}
- }
- return try await getSuggestion(request, workspaceInfo)
+ return try await getSuggestion(request, workspaceInfo)
+ } catch let error as SuggestionServiceError {
+ throw error
+ } catch {
+ throw SuggestionServiceError.silent(error)
+ }
}
func notifyAccepted(
- _ suggestion: SuggestionModel.CodeSuggestion,
+ _ suggestion: SuggestionBasic.CodeSuggestion,
workspaceInfo: CopilotForXcodeKit.WorkspaceInfo
) async {
+ eventHandlers.forEach { $0.didAccept(suggestion, workspaceInfo: workspaceInfo) }
await suggestionProvider.notifyAccepted(suggestion, workspaceInfo: workspaceInfo)
}
func notifyRejected(
- _ suggestions: [SuggestionModel.CodeSuggestion],
+ _ 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 27450898..022b424c 100644
--- a/Core/Sources/SuggestionWidget/ChatPanelWindow.swift
+++ b/Core/Sources/SuggestionWidget/ChatPanelWindow.swift
@@ -4,23 +4,38 @@ import ComposableArchitecture
import Foundation
import SwiftUI
-final class ChatPanelWindow: NSWindow {
+final class ChatPanelWindow: WidgetWindow {
override var canBecomeKey: Bool { true }
override var canBecomeMain: Bool { true }
private let storeObserver = NSObject()
+ private let store: StoreOf
var minimizeWindow: () -> Void = {}
+ var isDetached: Bool {
+ store.withState { $0.isDetached }
+ }
+
+ override var defaultCollectionBehavior: NSWindow.CollectionBehavior {
+ [
+ .fullScreenAuxiliary,
+ .transient,
+ .fullScreenPrimary,
+ .fullScreenAllowsTiling,
+ ]
+ }
+
init(
- store: StoreOf,
+ store: StoreOf,
chatTabPool: ChatTabPool,
minimizeWindow: @escaping () -> Void
) {
+ self.store = store
self.minimizeWindow = minimizeWindow
super.init(
- contentRect: .zero,
- styleMask: [.resizable, .titled, .miniaturizable, .fullSizeContentView],
+ contentRect: .init(x: 0, y: 0, width: 300, height: 400),
+ styleMask: [.resizable, .titled, .miniaturizable, .fullSizeContentView, .closable],
backing: .buffered,
defer: false
)
@@ -36,15 +51,7 @@ final class ChatPanelWindow: NSWindow {
}())
titlebarAppearsTransparent = true
isReleasedWhenClosed = false
- isOpaque = false
- backgroundColor = .clear
- level = .init(NSWindow.Level.floating.rawValue + 1)
- collectionBehavior = [
- .fullScreenAuxiliary,
- .transient,
- .fullScreenPrimary,
- .fullScreenAllowsTiling,
- ]
+ level = widgetLevel(1)
hasShadow = true
contentView = NSHostingView(
rootView: ChatWindowView(
@@ -59,9 +66,12 @@ final class ChatPanelWindow: NSWindow {
setIsVisible(true)
isPanelDisplayed = false
+ var wasDetached = false
storeObserver.observe { [weak self] in
guard let self else { return }
let isDetached = store.isDetached
+ guard isDetached != wasDetached else { return }
+ wasDetached = isDetached
Task { @MainActor in
if UserDefaults.shared.value(for: \.disableFloatOnTopWhenTheChatPanelIsDetached) {
self.setFloatOnTop(!isDetached)
@@ -72,14 +82,9 @@ final class ChatPanelWindow: NSWindow {
}
}
- func setFloatOnTop(_ isFloatOnTop: Bool) {
- let targetLevel: NSWindow.Level = isFloatOnTop
- ? .init(NSWindow.Level.floating.rawValue + 1)
- : .normal
-
- if targetLevel != level {
- level = targetLevel
- }
+ func centerInActiveSpaceIfNeeded() {
+ guard !isOnActiveSpace else { return }
+ center()
}
var isWindowHidden: Bool = false {
@@ -103,5 +108,9 @@ final class ChatPanelWindow: NSWindow {
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 f73f3070..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
@@ -174,34 +176,42 @@ struct ChatTabBar: View {
ScrollView(.horizontal) {
HStack(spacing: 0) {
ForEach(tabInfo, id: \.id) { info in
- if let tab = chatTabPool.getTab(of: info.id) {
- ChatTabBarButton(
- store: store,
- info: info,
- content: { tab.tabItem },
- icon: { tab.icon },
- isSelected: info.id == selectedTabId
- )
- .contextMenu {
- tab.menu
- }
- .id(info.id)
- .onDrag {
- draggingTabId = info.id
- return NSItemProvider(object: info.id as NSString)
- }
- .onDrop(
- of: [.text],
- delegate: ChatTabBarDropDelegate(
+ WithPerceptionTracking {
+ if let tab = chatTabPool.getTab(of: info.id) {
+ ChatTabBarButton(
store: store,
- tabs: tabInfo,
- itemId: info.id,
- draggingTabId: $draggingTabId
+ info: info,
+ content: { tab.tabItem },
+ icon: { tab.icon },
+ isSelected: info.id == selectedTabId
+ )
+ .contextMenu {
+ tab.menu
+ }
+ .id(info.id)
+ .onDrag {
+ draggingTabId = info.id
+ return NSItemProvider(object: info.id as NSString)
+ }
+ .onDrop(
+ of: [.text],
+ delegate: ChatTabBarDropDelegate(
+ store: store,
+ tabs: tabInfo,
+ itemId: info.id,
+ draggingTabId: $draggingTabId
+ )
)
- )
- } else {
- EmptyView()
+ } else {
+ ChatTabBarButton(
+ store: store,
+ info: info,
+ content: { Text("Not Found") },
+ icon: { Image(systemName: "questionmark.diamond") },
+ isSelected: info.id == selectedTabId
+ )
+ }
}
}
}
@@ -218,33 +228,35 @@ struct ChatTabBar: View {
}
struct CreateButton: View {
- let store: StoreOf
+ let store: StoreOf
var body: some View {
WithPerceptionTracking {
let collection = store.chatTabGroup.tabCollection
Menu {
ForEach(0..
+ let store: StoreOf
let tabs: IdentifiedArray
let itemId: String
@Binding var draggingTabId: String?
@@ -292,7 +304,7 @@ struct ChatTabBarDropDelegate: DropDelegate {
}
struct ChatTabBarButton: View {
- let store: StoreOf
+ let store: StoreOf
let info: ChatTabInfo
let content: () -> Content
let icon: () -> Icon
@@ -337,7 +349,7 @@ struct ChatTabBarButton: View {
}
struct ChatTabContainer: View {
- let store: StoreOf
+ let store: StoreOf
@Environment(\.chatTabPool) var chatTabPool
var body: some View {
@@ -365,7 +377,7 @@ struct ChatTabContainer: View {
anchor: .topLeading
)
} else {
- EmptyView()
+ Text("404 Not Found")
}
}
}
@@ -396,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: [
@@ -412,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 ef5d1155..00000000
--- a/Core/Sources/SuggestionWidget/CursorPositionTracker.swift
+++ /dev/null
@@ -1,53 +0,0 @@
-import Combine
-import Foundation
-import Perception
-import SuggestionModel
-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 94%
rename from Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift
rename to Core/Sources/SuggestionWidget/FeatureReducers/ChatPanel.swift
index a97ba373..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
@@ -125,7 +126,7 @@ public struct ChatPanelFeature {
await send(.attachChatPanel)
}
}
-
+
state.isDetached.toggle()
return .none
@@ -161,7 +162,11 @@ public struct ChatPanelFeature {
}
state.isPanelDisplayed = true
return .run { send in
- activateExtensionService()
+ if forceDetach {
+ await suggestionWidgetControllerDependency.windowsController?.windows
+ .chatPanelWindow
+ .centerInActiveSpaceIfNeeded()
+ }
await send(.focusActiveChatTab)
}
@@ -193,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
}
@@ -274,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))
}
@@ -285,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 90%
rename from Core/Sources/SuggestionWidget/FeatureReducers/CircularWidgetFeature.swift
rename to Core/Sources/SuggestionWidget/FeatureReducers/CircularWidget.swift
index 40e95e62..8b173c30 100644
--- a/Core/Sources/SuggestionWidget/FeatureReducers/CircularWidgetFeature.swift
+++ b/Core/Sources/SuggestionWidget/FeatureReducers/CircularWidget.swift
@@ -1,11 +1,11 @@
import ActiveApplicationMonitor
import ComposableArchitecture
import Preferences
-import SuggestionModel
+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 02c3b797..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 SuggestionModel
-
-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 ad644677..d844b336 100644
--- a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift
+++ b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift
@@ -1,148 +1,111 @@
import ComposableArchitecture
import Foundation
import PromptToCodeService
-import SuggestionModel
+import SuggestionBasic
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 85%
rename from Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift
rename to Core/Sources/SuggestionWidget/FeatureReducers/Widget.swift
index 6e8ee37f..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,
@@ -85,16 +85,14 @@ public struct WidgetFeature {
private enum CancelID {
case observeActiveApplicationChange
case observeCompletionPanelChange
- case observeFullscreenChange
case observeWindowChange
case observeEditorChange
case observeUserDefaults
}
- public enum Action: Equatable {
+ public enum Action {
case startup
case observeActiveApplicationChange
- case observeFullscreenChange
case observeColorSchemeChange
case updateActiveApplication
@@ -106,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? {
@@ -134,7 +132,7 @@ public struct WidgetFeature {
}
Scope(state: \._internalCircularWidgetState, action: \.circularWidget) {
- CircularWidgetFeature()
+ CircularWidget()
}
Reduce { state, action in
@@ -183,11 +181,11 @@ public struct WidgetFeature {
}
Scope(state: \.panelState, action: \.panel) {
- PanelFeature()
+ WidgetPanel()
}
Scope(state: \.chatPanelState, action: \.chatPanel) {
- ChatPanelFeature()
+ ChatPanel()
}
Reduce { state, action in
@@ -227,7 +225,6 @@ public struct WidgetFeature {
.run { send in
await send(.toastPanel(.start))
await send(.observeActiveApplicationChange)
- await send(.observeFullscreenChange)
await send(.observeColorSchemeChange)
}
)
@@ -235,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()
}
}
@@ -254,24 +257,6 @@ public struct WidgetFeature {
}
}.cancellable(id: CancelID.observeActiveApplicationChange, cancelInFlight: true)
- case .observeFullscreenChange:
- return .run { _ in
- let sequence = NSWorkspace.shared.notificationCenter
- .notifications(named: NSWorkspace.activeSpaceDidChangeNotification)
- for await _ in sequence {
- try Task.checkCancellation()
- guard let activeXcode = await xcodeInspector.safe.activeXcode
- else { continue }
- guard let windowsController,
- await windowsController.windows.fullscreenDetector.isOnActiveSpace
- else { continue }
- let app = activeXcode.appElement
- if let _ = app.focusedWindow {
- await windowsController.windows.orderFront()
- }
- }
- }.cancellable(id: CancelID.observeFullscreenChange, cancelInFlight: true)
-
case .observeColorSchemeChange:
return .run { send in
await send(.updateColorScheme)
@@ -326,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 3e2c64be..00000000
--- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift
+++ /dev/null
@@ -1,249 +0,0 @@
-import Combine
-import Perception
-import SharedUIComponents
-import SuggestionModel
-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 275ea26f..00000000
--- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift
+++ /dev/null
@@ -1,580 +0,0 @@
-import ComposableArchitecture
-import MarkdownUI
-import SharedUIComponents
-import SuggestionModel
-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 64443570..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 SuggestionModel
+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 c6e3455f..2f70e0e3 100644
--- a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift
+++ b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift
@@ -1,24 +1,28 @@
import AppKit
import AsyncAlgorithms
import ChatTab
-import Combine
import ComposableArchitecture
import Dependencies
import Foundation
+import SharedUIComponents
+import SwiftNavigation
import SwiftUI
import XcodeInspector
+#warning("""
+TODO: This part is too messy, consider breaking it up, let each window handle their own things
+""")
+
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?
@@ -29,14 +33,16 @@ actor WidgetWindowsController: NSObject {
var lastUpdateWindowLocationTime = Date(timeIntervalSince1970: 0)
var beatingCompletionPanelTask: Task?
+ var updateWindowStateTask: Task?
deinit {
userDefaultsObservers.presentationModeChangeObserver.onChange = {}
observeToAppTask?.cancel()
observeToFocusedEditorTask?.cancel()
+ updateWindowStateTask?.cancel()
}
- init(store: StoreOf, chatTabPool: ChatTabPool) {
+ init(store: StoreOf, chatTabPool: ChatTabPool) {
self.store = store
self.chatTabPool = chatTabPool
windows = .init(store: store, chatTabPool: chatTabPool)
@@ -44,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
@@ -73,6 +86,27 @@ actor WidgetWindowsController: NSObject {
await self?.send(.updateColorScheme)
}
}
+
+ 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()
+ }
+ }
+ }
+ }
+
+ Task { @MainActor in
+ windows.chatPanelWindow.isPanelDisplayed = false
+ }
}
}
@@ -90,6 +124,7 @@ private extension WidgetWindowsController {
await hideSuggestionPanelWindow()
}
await adjustChatPanelWindowLevel()
+ await adjustModificationPanelLevel()
}
guard currentApplicationProcessIdentifier != app.processIdentifier else { return }
currentApplicationProcessIdentifier = app.processIdentifier
@@ -103,39 +138,45 @@ 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, .focusedUIElementChanged:
+ case .focusedWindowChanged:
+ await handleSpaceChange()
+ await hideWidgetForTransitions()
+ await updateWidgetsAndNotifyChangeOfEditor(immediately: true)
+ case .focusedUIElementChanged:
await hideWidgetForTransitions()
await updateWidgetsAndNotifyChangeOfEditor(immediately: true)
case .applicationActivated:
@@ -174,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
@@ -187,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
@@ -224,7 +265,7 @@ private extension WidgetWindowsController {
extension WidgetWindowsController {
@MainActor
func hidePanelWindows() {
- windows.sharedPanelWindow.alphaValue = 0
+// windows.sharedPanelWindow.alphaValue = 0
windows.suggestionPanelWindow.alphaValue = 0
}
@@ -233,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)
@@ -249,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
@@ -269,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 {
@@ -280,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
@@ -307,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
)
}
}
@@ -355,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 }
@@ -390,30 +421,27 @@ 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
} else if previousAppIsXcode {
- 1
+ if windows.chatPanelWindow.isFullscreen,
+ windows.chatPanelWindow.isOnActiveSpace
+ {
+ 0
+ } else {
+ 1
+ }
} else {
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
- }
}
}
}
@@ -445,7 +473,7 @@ extension WidgetWindowsController {
animate: animated
)
windows.sharedPanelWindow.setFrame(
- widgetLocation.defaultPanelLocation.frame,
+ widgetLocation.sharedPanelLocation.frame,
display: false,
animate: animated
)
@@ -469,6 +497,7 @@ extension WidgetWindowsController {
}
await adjustChatPanelWindowLevel()
+ await adjustModificationPanelLevel()
}
let now = Date()
@@ -485,7 +514,7 @@ extension WidgetWindowsController {
)
updateWindowLocationTask = Task {
- try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
+ try await Task.sleep(nanoseconds: UInt64(delay * 500_000_000))
try Task.checkCancellation()
await update()
}
@@ -497,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 {
@@ -543,8 +582,62 @@ extension WidgetWindowsController {
}
return false
}
+ return overlap
+ }()
+
+ if latestAppIsXcodeOrExtension {
+ if floatOnTopWhenOverlapsXcode {
+ let overlap = await overlap
+ window.setFloatOnTop(overlap)
+ } else {
+ if disableFloatOnTopWhenTheChatPanelIsDetached, isChatPanelDetached {
+ window.setFloatOnTop(false)
+ } else {
+ window.setFloatOnTop(true)
+ }
+ }
+ } else {
+ if floatOnTopWhenOverlapsXcode {
+ let overlap = await overlap
+ window.setFloatOnTop(overlap)
+ } else {
+ switch flowOnTopOption {
+ case .onTopWhenXcodeIsActive:
+ window.setFloatOnTop(false)
+ case .alwaysOnTop:
+ window.setFloatOnTop(true)
+ case .never:
+ window.setFloatOnTop(false)
+ }
+ }
+ }
+ }
+
+ @MainActor
+ func handleSpaceChange() async {
+ let activeXcode = XcodeInspector.shared.activeXcode
+
+ let xcode = activeXcode?.appElement
+
+ let isXcodeActive = xcode?.isFrontmost ?? false
+
+ [
+ windows.sharedPanelWindow,
+ windows.suggestionPanelWindow,
+ windows.widgetWindow,
+ windows.toastWindow,
+ ].forEach {
+ if isXcodeActive {
+ $0.moveToActiveSpace()
+ }
+ }
- window.setFloatOnTop(overlap)
+ if isXcodeActive, !windows.chatPanelWindow.isDetached {
+ windows.chatPanelWindow.moveToActiveSpace()
+ }
+
+ if windows.fullscreenDetector.isOnActiveSpace, xcode?.focusedWindow != nil {
+ windows.orderFront()
}
}
}
@@ -596,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.
@@ -612,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
@@ -623,18 +714,16 @@ public final class WidgetWindows {
@MainActor
lazy var widgetWindow = {
- let it = CanBecomeKeyWindow(
+ let it = WidgetWindow(
contentRect: .zero,
styleMask: .borderless,
backing: .buffered,
defer: false
)
it.isReleasedWhenClosed = false
- it.isOpaque = false
it.backgroundColor = .clear
- it.level = .floating
- it.collectionBehavior = [.fullScreenAuxiliary, .transient]
- it.hasShadow = true
+ it.level = widgetLevel(0)
+ it.hasShadow = false
it.contentView = NSHostingView(
rootView: WidgetView(
store: store.scope(
@@ -650,18 +739,17 @@ public final class WidgetWindows {
@MainActor
lazy var sharedPanelWindow = {
- let it = CanBecomeKeyWindow(
+ let it = WidgetWindow(
contentRect: .init(x: 0, y: 0, width: Style.panelWidth, height: Style.panelHeight),
styleMask: .borderless,
backing: .buffered,
defer: false
)
it.isReleasedWhenClosed = false
- it.isOpaque = false
it.backgroundColor = .clear
- it.level = .init(NSWindow.Level.floating.rawValue + 2)
- it.collectionBehavior = [.fullScreenAuxiliary, .transient]
- it.hasShadow = true
+ it.level = widgetLevel(2)
+ it.hoveringLevel = widgetLevel(2)
+ it.hasShadow = false
it.contentView = NSHostingView(
rootView: SharedPanelView(
store: store.scope(
@@ -671,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
@@ -684,18 +772,18 @@ public final class WidgetWindows {
@MainActor
lazy var suggestionPanelWindow = {
- let it = CanBecomeKeyWindow(
+ let it = WidgetWindow(
contentRect: .init(x: 0, y: 0, width: Style.panelWidth, height: Style.panelHeight),
styleMask: .borderless,
backing: .buffered,
defer: false
)
it.isReleasedWhenClosed = false
- it.isOpaque = false
it.backgroundColor = .clear
- it.level = .init(NSWindow.Level.floating.rawValue + 2)
- it.collectionBehavior = [.fullScreenAuxiliary, .transient]
- it.hasShadow = true
+ it.level = widgetLevel(2)
+ it.hasShadow = false
+ it.menu = nil
+ it.animationBehavior = .utilityWindow
it.contentView = NSHostingView(
rootView: SuggestionPanelView(
store: store.scope(
@@ -705,7 +793,7 @@ public final class WidgetWindows {
state: \.suggestionPanelState,
action: \.suggestionPanel
)
- ).environment(cursorPositionTracker)
+ )
)
it.canBecomeKeyChecker = { false }
it.setIsVisible(true)
@@ -724,23 +812,23 @@ public final class WidgetWindows {
self?.store.send(.chatPanel(.hideButtonClicked))
}
)
+ it.hoveringLevel = widgetLevel(1)
it.delegate = controller
return it
}()
@MainActor
lazy var toastWindow = {
- let it = CanBecomeKeyWindow(
- contentRect: .zero,
+ let it = WidgetWindow(
+ 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 = .floating
- it.collectionBehavior = [.fullScreenAuxiliary, .transient]
+ it.level = widgetLevel(2)
it.hasShadow = false
it.contentView = NSHostingView(
rootView: ToastPanelView(store: store.scope(
@@ -749,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
@@ -768,7 +855,9 @@ public final class WidgetWindows {
toastWindow.orderFrontRegardless()
sharedPanelWindow.orderFrontRegardless()
suggestionPanelWindow.orderFrontRegardless()
- if chatPanelWindow.level.rawValue > NSWindow.Level.normal.rawValue {
+ if chatPanelWindow.level.rawValue > NSWindow.Level.normal.rawValue,
+ store.withState({ !$0.chatPanelState.isDetached })
+ {
chatPanelWindow.orderFrontRegardless()
}
}
@@ -782,3 +871,84 @@ class CanBecomeKeyWindow: NSWindow {
override var canBecomeMain: Bool { canBecomeKeyChecker() }
}
+class WidgetWindow: CanBecomeKeyWindow {
+ enum State: Equatable {
+ case normal(fullscreen: Bool)
+ case switchingSpace
+ }
+
+ var hoveringLevel: NSWindow.Level = widgetLevel(0)
+
+ override var isFloatingPanel: Bool { true }
+
+ var defaultCollectionBehavior: NSWindow.CollectionBehavior {
+ [.fullScreenAuxiliary, .transient]
+ }
+
+ var isFullscreen: Bool {
+ styleMask.contains(.fullScreen)
+ }
+
+ private var state: State? {
+ didSet {
+ guard state != oldValue else { return }
+ switch state {
+ case .none:
+ collectionBehavior = defaultCollectionBehavior
+ case .switchingSpace:
+ collectionBehavior = defaultCollectionBehavior.union(.moveToActiveSpace)
+ case .normal:
+ collectionBehavior = defaultCollectionBehavior
+ }
+ }
+ }
+
+ 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/HighlightJSThemeTemplate.swift b/Core/Sources/XcodeThemeController/HighlightJSThemeTemplate.swift
new file mode 100644
index 00000000..40e14b66
--- /dev/null
+++ b/Core/Sources/XcodeThemeController/HighlightJSThemeTemplate.swift
@@ -0,0 +1,107 @@
+import Foundation
+
+func buildHighlightJSTheme(_ theme: XcodeTheme) -> String {
+ /// The source value is an `r g b a` string, for example: `0.5 0.5 0.2 1`
+
+ return """
+ .hljs {
+ display: block;
+ overflow-x: auto;
+ padding: 0.5em;
+ background: \(theme.backgroundColor.hexString);
+ color: \(theme.plainTextColor.hexString);
+ }
+ .xml .hljs-meta {
+ color: \(theme.marksColor.hexString);
+ }
+ .hljs-comment,
+ .hljs-quote {
+ color: \(theme.commentColor.hexString);
+ }
+ .hljs-tag,
+ .hljs-keyword,
+ .hljs-selector-tag,
+ .hljs-literal,
+ .hljs-name {
+ color: \(theme.keywordsColor.hexString);
+ }
+ .hljs-attribute {
+ color: \(theme.attributesColor.hexString);
+ }
+ .hljs-variable,
+ .hljs-template-variable {
+ color: \(theme.otherPropertiesAndGlobalsColor.hexString);
+ }
+ .hljs-code,
+ .hljs-string,
+ .hljs-meta-string {
+ color: \(theme.stringsColor.hexString);
+ }
+ .hljs-regexp {
+ color: \(theme.regexLiteralsColor.hexString);
+ }
+ .hljs-link {
+ color: \(theme.urlsColor.hexString);
+ }
+ .hljs-title {
+ color: \(theme.headingColor.hexString);
+ }
+ .hljs-symbol,
+ .hljs-bullet {
+ color: \(theme.attributesColor.hexString);
+ }
+ .hljs-number {
+ color: \(theme.numbersColor.hexString);
+ }
+ .hljs-section {
+ color: \(theme.marksColor.hexString);
+ }
+ .hljs-meta {
+ color: \(theme.keywordsColor.hexString);
+ }
+ .hljs-type,
+ .hljs-built_in,
+ .hljs-builtin-name {
+ color: \(theme.otherTypeNamesColor.hexString);
+ }
+ .hljs-class .hljs-title,
+ .hljs-title .class_ {
+ color: \(theme.typeDeclarationsColor.hexString);
+ }
+ .hljs-function .hljs-title,
+ .hljs-title .function_ {
+ color: \(theme.otherDeclarationsColor.hexString);
+ }
+ .hljs-params {
+ color: \(theme.otherDeclarationsColor.hexString);
+ }
+ .hljs-attr {
+ color: \(theme.attributesColor.hexString);
+ }
+ .hljs-subst {
+ color: \(theme.plainTextColor.hexString);
+ }
+ .hljs-formula {
+ background-color: \(theme.selectionColor.hexString);
+ font-style: italic;
+ }
+ .hljs-addition {
+ background-color: #baeeba;
+ }
+ .hljs-deletion {
+ background-color: #ffc8bd;
+ }
+ .hljs-selector-id,
+ .hljs-selector-class {
+ color: \(theme.plainTextColor.hexString);
+ }
+ .hljs-doctag,
+ .hljs-strong {
+ font-weight: bold;
+ }
+ .hljs-emphasis {
+ font-style: italic;
+ }
+ """
+}
+
diff --git a/Core/Sources/XcodeThemeController/HighlightrThemeManager.swift b/Core/Sources/XcodeThemeController/HighlightrThemeManager.swift
new file mode 100644
index 00000000..f5536e3c
--- /dev/null
+++ b/Core/Sources/XcodeThemeController/HighlightrThemeManager.swift
@@ -0,0 +1,89 @@
+import Foundation
+import Highlightr
+import Preferences
+
+public class HighlightrThemeManager: ThemeManager {
+ let defaultManager: ThemeManager
+
+ weak var controller: XcodeThemeController?
+
+ public init(defaultManager: ThemeManager, controller: XcodeThemeController) {
+ self.defaultManager = defaultManager
+ self.controller = controller
+ }
+
+ public func theme(for name: String) -> Theme? {
+ let syncSuggestionTheme = UserDefaults.shared.value(for: \.syncSuggestionHighlightTheme)
+ let syncPromptToCodeTheme = UserDefaults.shared.value(for: \.syncPromptToCodeHighlightTheme)
+ let syncChatTheme = UserDefaults.shared.value(for: \.syncChatCodeHighlightTheme)
+
+ lazy var defaultLight = Theme(themeString: defaultLightTheme)
+ lazy var defaultDark = Theme(themeString: defaultDarkTheme)
+
+ switch name {
+ case "suggestion-light":
+ guard syncSuggestionTheme, let theme = theme(lightMode: true) else {
+ return defaultLight
+ }
+ return theme
+ case "suggestion-dark":
+ guard syncSuggestionTheme, let theme = theme(lightMode: false) else {
+ return defaultDark
+ }
+ return theme
+ case "promptToCode-light":
+ guard syncPromptToCodeTheme, let theme = theme(lightMode: true) else {
+ return defaultLight
+ }
+ return theme
+ case "promptToCode-dark":
+ guard syncPromptToCodeTheme, let theme = theme(lightMode: false) else {
+ return defaultDark
+ }
+ return theme
+ case "chat-light":
+ guard syncChatTheme, let theme = theme(lightMode: true) else {
+ return defaultLight
+ }
+ return theme
+ case "chat-dark":
+ guard syncChatTheme, let theme = theme(lightMode: false) else {
+ return defaultDark
+ }
+ return theme
+ case "light":
+ return defaultLight
+ case "dark":
+ return defaultDark
+ default:
+ return defaultLight
+ }
+ }
+
+ func theme(lightMode: Bool) -> Theme? {
+ guard let controller else { return nil }
+ guard let directories = controller.createSupportDirectoriesIfNeeded() else { return nil }
+
+ let themeURL: URL = if lightMode {
+ directories.themeDirectory.appendingPathComponent("highlightjs-light")
+ } else {
+ directories.themeDirectory.appendingPathComponent("highlightjs-dark")
+ }
+
+ if let themeString = try? String(contentsOf: themeURL) {
+ return Theme(themeString: themeString)
+ }
+
+ controller.syncXcodeThemeIfNeeded()
+
+ if let themeString = try? String(contentsOf: themeURL) {
+ return Theme(themeString: themeString)
+ }
+
+ return nil
+ }
+}
+
+let defaultLightTheme = ".hljs{display:block;overflow-x:auto;padding:0.5em;background:#FFFFFFFF;color:#000000D8}.xml .hljs-meta{color:#495460FF}.hljs-comment,.hljs-quote{color:#5D6B79FF}.hljs-tag,.hljs-keyword,.hljs-selector-tag,.hljs-literal,.hljs-name{color:#9A2393FF}.hljs-attribute{color:#805E03FF}.hljs-variable,.hljs-template-variable{color:#6B36A9FF}.hljs-code,.hljs-string,.hljs-meta-string{color:#C31A15FF}.hljs-regexp{color:#000000D8}.hljs-link{color:#0E0EFFFF}.hljs-title{color:#000000FF}.hljs-symbol,.hljs-bullet{color:#805E03FF}.hljs-number{color:#1C00CFFF}.hljs-section{color:#495460FF}.hljs-meta{color:#9A2393FF}.hljs-type,.hljs-built_in,.hljs-builtin-name{color:#3900A0FF}.hljs-class .hljs-title,.hljs-title .class_{color:#0B4F79FF}.hljs-function .hljs-title,.hljs-title .function_{color:#0E67A0FF}.hljs-params{color:#0E67A0FF}.hljs-attr{color:#805E03FF}.hljs-subst{color:#000000D8}.hljs-formula{background-color:#A3CCFEFF;font-style:italic}.hljs-addition{background-color:#baeeba}.hljs-deletion{background-color:#ffc8bd}.hljs-selector-id,.hljs-selector-class{color:#000000D8}.hljs-doctag,.hljs-strong{font-weight:bold}.hljs-emphasis{font-style:italic}"
+
+let defaultDarkTheme = ".hljs{display:block;overflow-x:auto;padding:0.5em;background:#1F1F23FF;color:#FFFFFFD8}.xml .hljs-meta{color:#91A1B1FF}.hljs-comment,.hljs-quote{color:#6B7985FF}.hljs-tag,.hljs-keyword,.hljs-selector-tag,.hljs-literal,.hljs-name{color:#FC5FA2FF}.hljs-attribute{color:#BF8554FF}.hljs-variable,.hljs-template-variable{color:#A166E5FF}.hljs-code,.hljs-string,.hljs-meta-string{color:#FC695DFF}.hljs-regexp{color:#FFFFFFD8}.hljs-link{color:#5482FEFF}.hljs-title{color:#FFFFFFFF}.hljs-symbol,.hljs-bullet{color:#BF8554FF}.hljs-number{color:#CFBF69FF}.hljs-section{color:#91A1B1FF}.hljs-meta{color:#FC5FA2FF}.hljs-type,.hljs-built_in,.hljs-builtin-name{color:#D0A7FEFF}.hljs-class .hljs-title,.hljs-title .class_{color:#5CD7FEFF}.hljs-function .hljs-title,.hljs-title .function_{color:#41A1BFFF}.hljs-params{color:#41A1BFFF}.hljs-attr{color:#BF8554FF}.hljs-subst{color:#FFFFFFD8}.hljs-formula{background-color:#505A6FFF;font-style:italic}.hljs-addition{background-color:#baeeba}.hljs-deletion{background-color:#ffc8bd}.hljs-selector-id,.hljs-selector-class{color:#FFFFFFD8}.hljs-doctag,.hljs-strong{font-weight:bold}.hljs-emphasis{font-style:italic}"
diff --git a/Core/Sources/XcodeThemeController/PreferenceKey+Theme.swift b/Core/Sources/XcodeThemeController/PreferenceKey+Theme.swift
new file mode 100644
index 00000000..0d46af1f
--- /dev/null
+++ b/Core/Sources/XcodeThemeController/PreferenceKey+Theme.swift
@@ -0,0 +1,27 @@
+import Foundation
+import Preferences
+
+// MARK: - Theming
+
+public extension UserDefaultPreferenceKeys {
+ var lightXcodeThemeName: PreferenceKey {
+ .init(defaultValue: "", key: "LightXcodeThemeName")
+ }
+
+ var lightXcodeTheme: PreferenceKey> {
+ .init(defaultValue: .init(nil), key: "LightXcodeTheme")
+ }
+
+ var darkXcodeThemeName: PreferenceKey {
+ .init(defaultValue: "", key: "DarkXcodeThemeName")
+ }
+
+ var darkXcodeTheme: PreferenceKey> {
+ .init(defaultValue: .init(nil), key: "LightXcodeTheme")
+ }
+
+ var lastSyncedHighlightJSThemeCreatedAt: PreferenceKey {
+ .init(defaultValue: 0, key: "LastSyncedHighlightJSThemeCreatedAt")
+ }
+}
+
diff --git a/Core/Sources/XcodeThemeController/XcodeThemeController.swift b/Core/Sources/XcodeThemeController/XcodeThemeController.swift
new file mode 100644
index 00000000..01118547
--- /dev/null
+++ b/Core/Sources/XcodeThemeController/XcodeThemeController.swift
@@ -0,0 +1,249 @@
+import AppKit
+import Foundation
+import Highlightr
+import XcodeInspector
+
+public class XcodeThemeController {
+ var syncTriggerTask: Task?
+
+ public init(syncTriggerTask: Task? = nil) {
+ self.syncTriggerTask = syncTriggerTask
+ }
+
+ public func start() {
+ let defaultHighlightrThemeManager = Highlightr.themeManager
+ Highlightr.themeManager = HighlightrThemeManager(
+ defaultManager: defaultHighlightrThemeManager,
+ controller: self
+ )
+
+ syncXcodeThemeIfNeeded()
+
+ syncTriggerTask?.cancel()
+ syncTriggerTask = Task { [weak self] in
+ let notifications = NSWorkspace.shared.notificationCenter
+ .notifications(named: NSWorkspace.didActivateApplicationNotification)
+ for await notification in notifications {
+ try Task.checkCancellation()
+ guard let app = notification
+ .userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication
+ else { continue }
+ guard app.isCopilotForXcodeExtensionService else { continue }
+ guard let self else { return }
+ self.syncXcodeThemeIfNeeded()
+ }
+ }
+ }
+}
+
+extension XcodeThemeController {
+ func syncXcodeThemeIfNeeded() {
+ guard UserDefaults.shared.value(for: \.syncSuggestionHighlightTheme)
+ || UserDefaults.shared.value(for: \.syncPromptToCodeHighlightTheme)
+ || UserDefaults.shared.value(for: \.syncChatCodeHighlightTheme)
+ else { return }
+ guard let directories = createSupportDirectoriesIfNeeded() else { return }
+
+ defer {
+ UserDefaults.shared.set(
+ Date().timeIntervalSince1970,
+ for: \.lastSyncedHighlightJSThemeCreatedAt
+ )
+ }
+
+ let xcodeUserDefaults = UserDefaults(suiteName: "com.apple.dt.Xcode")!
+
+ if let darkThemeName = xcodeUserDefaults
+ .value(forKey: "XCFontAndColorCurrentDarkTheme") as? String
+ {
+ syncXcodeThemeIfNeeded(
+ xcodeThemeName: darkThemeName,
+ light: false,
+ in: directories.themeDirectory
+ )
+ }
+
+ if let lightThemeName = xcodeUserDefaults
+ .value(forKey: "XCFontAndColorCurrentTheme") as? String
+ {
+ syncXcodeThemeIfNeeded(
+ xcodeThemeName: lightThemeName,
+ light: true,
+ in: directories.themeDirectory
+ )
+ }
+ }
+
+ func syncXcodeThemeIfNeeded(
+ xcodeThemeName: String,
+ light: Bool,
+ in directoryURL: URL
+ ) {
+ let targetName = light ? "highlightjs-light" : "highlightjs-dark"
+ guard let xcodeThemeURL = locateXcodeTheme(named: xcodeThemeName) else { return }
+ let targetThemeURL = directoryURL.appendingPathComponent(targetName)
+ let lastSyncTimestamp = UserDefaults.shared
+ .value(for: \.lastSyncedHighlightJSThemeCreatedAt)
+
+ let shouldSync = {
+ if light, UserDefaults.shared.value(for: \.lightXcodeTheme) == nil { return true }
+ if !light, UserDefaults.shared.value(for: \.darkXcodeTheme) == nil { return true }
+ if light, xcodeThemeName != UserDefaults.shared.value(for: \.lightXcodeThemeName) {
+ return true
+ }
+ if !light, xcodeThemeName != UserDefaults.shared.value(for: \.darkXcodeThemeName) {
+ return true
+ }
+ if !FileManager.default.fileExists(atPath: targetThemeURL.path) { return true }
+
+ let xcodeThemeFileUpdated = {
+ guard let xcodeThemeModifiedDate = try? xcodeThemeURL
+ .resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate
+ else { return true }
+ return xcodeThemeModifiedDate.timeIntervalSince1970 > lastSyncTimestamp
+ }()
+
+ if xcodeThemeFileUpdated { return true }
+
+ return false
+ }()
+
+ if shouldSync {
+ do {
+ let theme = try XcodeTheme(fileURL: xcodeThemeURL)
+ let highlightrTheme = theme.asHighlightJSTheme()
+ try highlightrTheme.write(to: targetThemeURL, atomically: true, encoding: .utf8)
+
+ Task { @MainActor in
+ if light {
+ UserDefaults.shared.set(xcodeThemeName, for: \.lightXcodeThemeName)
+ UserDefaults.shared.set(.init(theme), for: \.lightXcodeTheme)
+ UserDefaults.shared.set(
+ .init(theme.plainTextColor.storable),
+ for: \.codeForegroundColorLight
+ )
+ UserDefaults.shared.set(
+ .init(theme.backgroundColor.storable),
+ for: \.codeBackgroundColorLight
+ )
+ } else {
+ UserDefaults.shared.set(xcodeThemeName, for: \.darkXcodeThemeName)
+ UserDefaults.shared.set(.init(theme), for: \.darkXcodeTheme)
+ UserDefaults.shared.set(
+ .init(theme.plainTextColor.storable),
+ for: \.codeForegroundColorDark
+ )
+ UserDefaults.shared.set(
+ .init(theme.backgroundColor.storable),
+ for: \.codeBackgroundColorDark
+ )
+ }
+ }
+ } catch {
+ print(error.localizedDescription)
+ }
+ }
+ }
+
+ func locateXcodeTheme(named name: String) -> URL? {
+ if let customThemeURL = FileManager.default.urls(
+ for: .libraryDirectory,
+ in: .userDomainMask
+ ).first?.appendingPathComponent("Developer/Xcode/UserData/FontAndColorThemes")
+ .appendingPathComponent(name),
+ FileManager.default.fileExists(atPath: customThemeURL.path)
+ {
+ return customThemeURL
+ }
+
+ let xcodeURL: URL? = {
+ if let running = NSWorkspace.shared
+ .urlForApplication(withBundleIdentifier: "com.apple.dt.Xcode")
+ {
+ return running
+ }
+ // Use the main Xcode.app
+ let proposedXcodeURL = URL(fileURLWithPath: "/Applications/Xcode.app")
+ if FileManager.default.fileExists(atPath: proposedXcodeURL.path) {
+ return proposedXcodeURL
+ }
+ // Look for an Xcode.app
+ if let applicationsURL = FileManager.default.urls(
+ for: .applicationDirectory,
+ in: .localDomainMask
+ ).first {
+ struct InfoPlist: Codable {
+ var CFBundleIdentifier: String
+ }
+
+ let appBundleIdentifier = "com.apple.dt.Xcode"
+ let appDirectories = try? FileManager.default.contentsOfDirectory(
+ at: applicationsURL,
+ includingPropertiesForKeys: [],
+ options: .skipsHiddenFiles
+ )
+ for appDirectoryURL in appDirectories ?? [] {
+ let infoPlistURL = appDirectoryURL.appendingPathComponent("Contents/Info.plist")
+ if let data = try? Data(contentsOf: infoPlistURL),
+ let infoPlist = try? PropertyListDecoder().decode(
+ InfoPlist.self,
+ from: data
+ ),
+ infoPlist.CFBundleIdentifier == appBundleIdentifier
+ {
+ return appDirectoryURL
+ }
+ }
+ }
+ return nil
+ }()
+
+ if let url = xcodeURL?
+ .appendingPathComponent("Contents/SharedFrameworks/DVTUserInterfaceKit.framework")
+ .appendingPathComponent("Versions/A/Resources/FontAndColorThemes")
+ .appendingPathComponent(name),
+ FileManager.default.fileExists(atPath: url.path)
+ {
+ return url
+ }
+
+ return nil
+ }
+
+ func createSupportDirectoriesIfNeeded() -> (supportDirectory: URL, themeDirectory: URL)? {
+ guard let supportURL = FileManager.default.urls(
+ for: .applicationSupportDirectory,
+ in: .userDomainMask
+ ).first?.appendingPathComponent(
+ Bundle.main
+ .object(forInfoDictionaryKey: "APPLICATION_SUPPORT_FOLDER") as! String
+ ) else {
+ return nil
+ }
+
+ let themeURL = supportURL.appendingPathComponent("Themes")
+
+ do {
+ if !FileManager.default.fileExists(atPath: supportURL.path) {
+ try FileManager.default.createDirectory(
+ at: supportURL,
+ withIntermediateDirectories: true,
+ attributes: nil
+ )
+ }
+
+ if !FileManager.default.fileExists(atPath: themeURL.path) {
+ try FileManager.default.createDirectory(
+ at: themeURL,
+ withIntermediateDirectories: true,
+ attributes: nil
+ )
+ }
+ } catch {
+ return nil
+ }
+
+ return (supportURL, themeURL)
+ }
+}
+
diff --git a/Core/Sources/XcodeThemeController/XcodeThemeParser.swift b/Core/Sources/XcodeThemeController/XcodeThemeParser.swift
new file mode 100644
index 00000000..b2a3cd53
--- /dev/null
+++ b/Core/Sources/XcodeThemeController/XcodeThemeParser.swift
@@ -0,0 +1,321 @@
+import Foundation
+import Preferences
+
+public struct XcodeTheme: Codable {
+ public struct ThemeColor: Codable {
+ public var red: Double
+ public var green: Double
+ public var blue: Double
+ public var alpha: Double
+
+ public var hexString: String {
+ let red = Int(self.red * 255)
+ let green = Int(self.green * 255)
+ let blue = Int(self.blue * 255)
+ let alpha = Int(self.alpha * 255)
+ return String(format: "#%02X%02X%02X%02X", red, green, blue, alpha)
+ }
+
+ var storable: StorableColor {
+ .init(red: red, green: green, blue: blue, alpha: alpha)
+ }
+ }
+
+ public var plainTextColor: ThemeColor
+ public var commentColor: ThemeColor
+ public var documentationMarkupColor: ThemeColor
+ public var documentationMarkupKeywordColor: ThemeColor
+ public var marksColor: ThemeColor
+ public var stringsColor: ThemeColor
+ public var charactersColor: ThemeColor
+ public var numbersColor: ThemeColor
+ public var regexLiteralsColor: ThemeColor
+ public var regexLiteralNumbersColor: ThemeColor
+ public var regexLiteralCaptureNamesColor: ThemeColor
+ public var regexLiteralCharacterClassNamesColor: ThemeColor
+ public var regexLiteralOperatorsColor: ThemeColor
+ public var keywordsColor: ThemeColor
+ public var preprocessorStatementsColor: ThemeColor
+ public var urlsColor: ThemeColor
+ public var attributesColor: ThemeColor
+ public var typeDeclarationsColor: ThemeColor
+ public var otherDeclarationsColor: ThemeColor
+ public var projectClassNamesColor: ThemeColor
+ public var projectFunctionAndMethodNamesColor: ThemeColor
+ public var projectConstantsColor: ThemeColor
+ public var projectTypeNamesColor: ThemeColor
+ public var projectPropertiesAndGlobalsColor: ThemeColor
+ public var projectPreprocessorMacrosColor: ThemeColor
+ public var otherClassNamesColor: ThemeColor
+ public var otherFunctionAndMethodNamesColor: ThemeColor
+ public var otherConstantsColor: ThemeColor
+ public var otherTypeNamesColor: ThemeColor
+ public var otherPropertiesAndGlobalsColor: ThemeColor
+ public var otherPreprocessorMacrosColor: ThemeColor
+ public var headingColor: ThemeColor
+ public var backgroundColor: ThemeColor
+ public var selectionColor: ThemeColor
+ public var cursorColor: ThemeColor
+ public var currentLineColor: ThemeColor
+ public var invisibleCharactersColor: ThemeColor
+ public var debuggerConsolePromptColor: ThemeColor
+ public var debuggerConsoleOutputColor: ThemeColor
+ public var debuggerConsoleInputColor: ThemeColor
+ public var executableConsoleOutputColor: ThemeColor
+ public var executableConsoleInputColor: ThemeColor
+
+ public func asHighlightJSTheme() -> String {
+ buildHighlightJSTheme(self)
+ .replacingOccurrences(of: "\n", with: "")
+ .replacingOccurrences(of: ": ", with: ":")
+ .replacingOccurrences(of: "} ", with: "}")
+ .replacingOccurrences(of: " {", with: "{")
+ .replacingOccurrences(of: ";}", with: "}")
+ .replacingOccurrences(of: " ", with: "")
+ }
+}
+
+public extension XcodeTheme {
+ /// Color scheme locations:
+ /// ~/Library/Developer/Xcode/UserData/FontAndColorThemes/
+ /// Xcode.app/Contents/SharedFrameworks/DVTUserInterfaceKit.framework/Versions/A/Resources/FontAndColorThemes
+ init(fileURL: URL) throws {
+ let parser = XcodeThemeParser()
+ self = try parser.parse(fileURL: fileURL)
+ }
+}
+
+struct XcodeThemeParser {
+ enum Error: Swift.Error {
+ case fileNotFound
+ case invalidData
+ }
+
+ func parse(fileURL: URL) throws -> XcodeTheme {
+ guard let data = try? Data(contentsOf: fileURL) else {
+ throw Error.fileNotFound
+ }
+
+ if fileURL.pathExtension == "xccolortheme" {
+ return try parseXCColorTheme(data)
+ } else {
+ throw Error.invalidData
+ }
+ }
+
+ func parseXCColorTheme(_ data: Data) throws -> XcodeTheme {
+ let plist = try? PropertyListSerialization.propertyList(
+ from: data,
+ options: .mutableContainers,
+ format: nil
+ ) as? [String: Any]
+
+ 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 convertColor(source: String) -> XcodeTheme.ThemeColor {
+ let components = source.split(separator: " ")
+ let red = (components[0] as NSString).doubleValue
+ let green = (components[1] as NSString).doubleValue
+ let blue = (components[2] as NSString).doubleValue
+ let alpha = (components[3] as NSString).doubleValue
+ return .init(red: red, green: green, blue: blue, alpha: alpha)
+ }
+
+ func getThemeValue(
+ at path: [String],
+ defaultValue: XcodeTheme.ThemeColor = .init(red: 0, green: 0, blue: 0, alpha: 1)
+ ) -> XcodeTheme.ThemeColor {
+ guard !path.isEmpty else { return defaultValue }
+ let keys = path.dropLast(1)
+ var currentDict = theme
+ for key in keys {
+ guard let value = currentDict[key] as? [String: Any] else {
+ return defaultValue
+ }
+ currentDict = value
+ }
+ if let value = currentDict[path.last!] as? String {
+ return convertColor(source: value)
+ }
+ return defaultValue
+ }
+
+ let black = XcodeTheme.ThemeColor(red: 0, green: 0, blue: 0, alpha: 1)
+ let white = XcodeTheme.ThemeColor(red: 1, green: 1, blue: 1, alpha: 1)
+
+ let xcodeTheme = XcodeTheme(
+ plainTextColor: getThemeValue(
+ at: ["DVTSourceTextSyntaxColors", "xcode.syntax.plain"],
+ defaultValue: black
+ ),
+ commentColor: getThemeValue(
+ at: ["DVTSourceTextSyntaxColors", "xcode.syntax.comment"],
+ defaultValue: black
+ ),
+ documentationMarkupColor: getThemeValue(
+ at: ["DVTSourceTextSyntaxColors", "xcode.syntax.comment.doc"],
+ defaultValue: black
+ ),
+ documentationMarkupKeywordColor: getThemeValue(
+ at: ["DVTSourceTextSyntaxColors", "xcode.syntax.comment.doc.keyword"],
+ defaultValue: black
+ ),
+ marksColor: getThemeValue(
+ at: ["DVTSourceTextSyntaxColors", "xcode.syntax.mark"],
+ defaultValue: black
+ ),
+ stringsColor: getThemeValue(
+ at: ["DVTSourceTextSyntaxColors", "xcode.syntax.string"],
+ defaultValue: black
+ ),
+ charactersColor: getThemeValue(
+ at: ["DVTSourceTextSyntaxColors", "xcode.syntax.character"],
+ defaultValue: black
+ ),
+ numbersColor: getThemeValue(
+ at: ["DVTSourceTextSyntaxColors", "xcode.syntax.number"],
+ defaultValue: black
+ ),
+ regexLiteralsColor: getThemeValue(
+ at: ["DVTSourceTextSyntaxColors", "xcode.syntax.plain"],
+ defaultValue: black
+ ),
+ regexLiteralNumbersColor: getThemeValue(
+ at: ["DVTSourceTextSyntaxColors", "xcode.syntax.number"],
+ defaultValue: black
+ ),
+ regexLiteralCaptureNamesColor: getThemeValue(
+ at: ["DVTSourceTextSyntaxColors", "xcode.syntax.plain"],
+ defaultValue: black
+ ),
+ regexLiteralCharacterClassNamesColor: getThemeValue(
+ at: ["DVTSourceTextSyntaxColors", "xcode.syntax.plain"],
+ defaultValue: black
+ ),
+ regexLiteralOperatorsColor: getThemeValue(
+ at: ["DVTSourceTextSyntaxColors", "xcode.syntax.plain"],
+ defaultValue: black
+ ),
+ keywordsColor: getThemeValue(
+ at: ["DVTSourceTextSyntaxColors", "xcode.syntax.keyword"],
+ defaultValue: black
+ ),
+ preprocessorStatementsColor: getThemeValue(
+ at: ["DVTSourceTextSyntaxColors", "xcode.syntax.preprocessor"],
+ defaultValue: black
+ ),
+ urlsColor: getThemeValue(
+ at: ["DVTSourceTextSyntaxColors", "xcode.syntax.url"],
+ defaultValue: black
+ ),
+ attributesColor: getThemeValue(
+ at: ["DVTSourceTextSyntaxColors", "xcode.syntax.attribute"],
+ defaultValue: black
+ ),
+ typeDeclarationsColor: getThemeValue(
+ at: ["DVTSourceTextSyntaxColors", "xcode.syntax.declaration.type"],
+ defaultValue: black
+ ),
+ otherDeclarationsColor: getThemeValue(
+ at: ["DVTSourceTextSyntaxColors", "xcode.syntax.declaration.other"],
+ defaultValue: black
+ ),
+ projectClassNamesColor: getThemeValue(
+ at: ["DVTSourceTextSyntaxColors", "xcode.syntax.identifier.class"],
+ defaultValue: black
+ ),
+ projectFunctionAndMethodNamesColor: getThemeValue(
+ at: ["DVTSourceTextSyntaxColors", "xcode.syntax.identifier.function"],
+ defaultValue: black
+ ),
+ projectConstantsColor: getThemeValue(
+ at: ["DVTSourceTextSyntaxColors", "xcode.syntax.identifier.constant"],
+ defaultValue: black
+ ),
+ projectTypeNamesColor: getThemeValue(
+ at: ["DVTSourceTextSyntaxColors", "xcode.syntax.identifier.type"],
+ defaultValue: black
+ ),
+ projectPropertiesAndGlobalsColor: getThemeValue(
+ at: ["DVTSourceTextSyntaxColors", "xcode.syntax.identifier.variable"],
+ defaultValue: black
+ ),
+ projectPreprocessorMacrosColor: getThemeValue(
+ at: ["DVTSourceTextSyntaxColors", "xcode.syntax.identifier.macro"],
+ defaultValue: black
+ ),
+ otherClassNamesColor: getThemeValue(
+ at: ["DVTSourceTextSyntaxColors", "xcode.syntax.identifier.class.system"],
+ defaultValue: black
+ ),
+ otherFunctionAndMethodNamesColor: getThemeValue(
+ at: ["DVTSourceTextSyntaxColors", "xcode.syntax.identifier.function.system"],
+ defaultValue: black
+ ),
+ otherConstantsColor: getThemeValue(
+ at: ["DVTSourceTextSyntaxColors", "xcode.syntax.identifier.constant.system"],
+ defaultValue: black
+ ),
+ otherTypeNamesColor: getThemeValue(
+ at: ["DVTSourceTextSyntaxColors", "xcode.syntax.identifier.type.system"],
+ defaultValue: black
+ ),
+ otherPropertiesAndGlobalsColor: getThemeValue(
+ at: ["DVTSourceTextSyntaxColors", "xcode.syntax.identifier.variable.system"],
+ defaultValue: black
+ ),
+ otherPreprocessorMacrosColor: getThemeValue(
+ at: ["DVTSourceTextSyntaxColors", "xcode.syntax.identifier.macro.system"],
+ defaultValue: black
+ ),
+ headingColor: getThemeValue(
+ at: ["DVTMarkupTextPrimaryHeadingColor"],
+ defaultValue: black
+ ),
+ backgroundColor: getThemeValue(
+ at: ["DVTSourceTextBackground"],
+ defaultValue: white
+ ),
+ selectionColor: getThemeValue(
+ at: ["DVTSourceTextSelectionColor"],
+ defaultValue: black
+ ),
+ cursorColor: getThemeValue(
+ at: ["DVTSourceTextInsertionPointColor"],
+ defaultValue: black
+ ),
+ currentLineColor: getThemeValue(
+ at: ["DVTSourceTextCurrentLineHighlightColor"],
+ defaultValue: black
+ ),
+ invisibleCharactersColor: getThemeValue(
+ at: ["DVTSourceTextInvisiblesColor"],
+ defaultValue: black
+ ),
+ debuggerConsolePromptColor: getThemeValue(
+ at: ["DVTConsoleDebuggerPromptTextColor"],
+ defaultValue: black
+ ),
+ debuggerConsoleOutputColor: getThemeValue(
+ at: ["DVTConsoleDebuggerOutputTextColor"],
+ defaultValue: black
+ ),
+ debuggerConsoleInputColor: getThemeValue(
+ at: ["DVTConsoleDebuggerInputTextColor"],
+ defaultValue: black
+ ),
+ executableConsoleOutputColor: getThemeValue(
+ at: ["DVTConsoleExectuableOutputTextColor"],
+ defaultValue: black
+ ),
+ executableConsoleInputColor: getThemeValue(
+ at: ["DVTConsoleExectuableInputTextColor"],
+ defaultValue: black
+ )
+ )
+
+ return xcodeTheme
+ }
+}
+
diff --git a/Core/Tests/KeyBindingManagerTests/TabToAcceptSuggestionTests.swift b/Core/Tests/KeyBindingManagerTests/TabToAcceptSuggestionTests.swift
new file mode 100644
index 00000000..b84e8ac0
--- /dev/null
+++ b/Core/Tests/KeyBindingManagerTests/TabToAcceptSuggestionTests.swift
@@ -0,0 +1,153 @@
+import Foundation
+import XCTest
+
+@testable import Workspace
+@testable import KeyBindingManager
+
+class TabToAcceptSuggestionTests: XCTestCase {
+ func test_should_accept_if_line_invalid() {
+ XCTAssertTrue(
+ TabToAcceptSuggestion.checkIfAcceptSuggestion(
+ lines: """
+ struct Cat {
+ var name: String
+ var age: Int
+ }
+ """.breakLines(),
+ cursorPosition: .init(line: 4, character: 4),
+ codeMetadata: .init(),
+ presentingSuggestionText: "Hello"
+ )
+ )
+
+ XCTAssertTrue(
+ TabToAcceptSuggestion.checkIfAcceptSuggestion(
+ lines: """
+ struct Cat {
+ var name: String
+ var age: Int
+ }
+ """.breakLines(),
+ cursorPosition: .init(line: -1, character: 4),
+ codeMetadata: .init(),
+ presentingSuggestionText: "Hello"
+ )
+ )
+ }
+
+ func test_should_not_accept_if_tab_does_not_invalidate_the_suggestion() {
+ XCTAssertFalse(
+ TabToAcceptSuggestion.checkIfAcceptSuggestion(
+ lines: """
+ struct Cat {
+
+ var age: Int
+ }
+ """.breakLines(),
+ cursorPosition: .init(line: 1, character: 0),
+ codeMetadata: .init(tabSize: 4, indentSize: 4, usesTabsForIndentation: false),
+ presentingSuggestionText: " var name: String"
+ )
+ )
+
+ XCTAssertFalse(
+ TabToAcceptSuggestion.checkIfAcceptSuggestion(
+ lines: """
+ struct 🐱 {
+
+ var 🎇: Int
+ }
+ """.breakLines(),
+ cursorPosition: .init(line: 1, character: 0),
+ codeMetadata: .init(tabSize: 4, indentSize: 4, usesTabsForIndentation: false),
+ presentingSuggestionText: " var 🎇: String"
+ )
+ )
+
+ XCTAssertFalse(
+ TabToAcceptSuggestion.checkIfAcceptSuggestion(
+ lines: """
+ struct Cat {
+
+ var age: Int
+ }
+ """.breakLines(),
+ cursorPosition: .init(line: 1, character: 0),
+ codeMetadata: .init(tabSize: 2, indentSize: 2, usesTabsForIndentation: false),
+ presentingSuggestionText: " var name: String"
+ )
+ )
+
+ XCTAssertFalse(
+ TabToAcceptSuggestion.checkIfAcceptSuggestion(
+ lines: """
+ struct Cat {
+
+ \tvar age: Int
+ }
+ """.breakLines(),
+ cursorPosition: .init(line: 1, character: 0),
+ codeMetadata: .init(tabSize: 4, indentSize: 1, usesTabsForIndentation: true),
+ presentingSuggestionText: "\tvar name: String"
+ )
+ )
+ }
+
+ func test_should_accept_if_tab_invalidates_the_suggestion() {
+ XCTAssertTrue(
+ TabToAcceptSuggestion.checkIfAcceptSuggestion(
+ lines: """
+ struct Cat {
+ \(" ")
+ var age: Int
+ }
+ """.breakLines(),
+ cursorPosition: .init(line: 1, character: 1),
+ codeMetadata: .init(tabSize: 4, indentSize: 4, usesTabsForIndentation: false),
+ presentingSuggestionText: " var name: String"
+ )
+ )
+
+ XCTAssertTrue(
+ TabToAcceptSuggestion.checkIfAcceptSuggestion(
+ lines: """
+ struct 🐱 {
+ \(" ")
+ var 🎇: Int
+ }
+ """.breakLines(),
+ cursorPosition: .init(line: 1, character: 1),
+ codeMetadata: .init(tabSize: 4, indentSize: 4, usesTabsForIndentation: false),
+ presentingSuggestionText: " var 🎇: String"
+ )
+ )
+
+ XCTAssertTrue(
+ TabToAcceptSuggestion.checkIfAcceptSuggestion(
+ lines: """
+ struct Cat {
+ \(" ")
+ var age: Int
+ }
+ """.breakLines(),
+ cursorPosition: .init(line: 1, character: 1),
+ codeMetadata: .init(tabSize: 2, indentSize: 2, usesTabsForIndentation: false),
+ presentingSuggestionText: " var name: String"
+ )
+ )
+
+ XCTAssertTrue(
+ TabToAcceptSuggestion.checkIfAcceptSuggestion(
+ lines: """
+ struct Cat {
+ \t
+ \tvar age: Int
+ }
+ """.breakLines(),
+ cursorPosition: .init(line: 1, character: 1),
+ codeMetadata: .init(tabSize: 4, indentSize: 1, usesTabsForIndentation: true),
+ presentingSuggestionText: "\tvar name: String"
+ )
+ )
+ }
+}
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 8dd99911..13a66210 100644
--- a/Core/Tests/ServiceTests/Environment.swift
+++ b/Core/Tests/ServiceTests/Environment.swift
@@ -2,7 +2,7 @@ import AppKit
import Client
import Foundation
import GitHubCopilotService
-import SuggestionModel
+import SuggestionBasic
import Workspace
import XCTest
import XPCShared
@@ -14,19 +14,23 @@ func completion(text: String, range: CursorRange, uuid: String = "") -> CodeSugg
}
class MockSuggestionService: GitHubCopilotSuggestionServiceType {
- func terminate() async {
+ func cancelOngoingTask(workDoneToken: String) async {
+ fatalError()
+ }
+
+ func notifyChangeTextDocument(fileURL: URL, content: String, version: Int) async throws {
fatalError()
}
- func cancelRequest() async {
+ func terminate() async {
fatalError()
}
- func notifyOpenTextDocument(fileURL: URL, content: String) async throws {
+ func cancelRequest() async {
fatalError()
}
- func notifyChangeTextDocument(fileURL: URL, content: String) async throws {
+ func notifyOpenTextDocument(fileURL: URL, content: String) async throws {
fatalError()
}
@@ -49,11 +53,12 @@ class MockSuggestionService: GitHubCopilotSuggestionServiceType {
func getCompletions(
fileURL: URL,
content: String,
- cursorPosition: SuggestionModel.CursorPosition,
+ originalContent: String,
+ cursorPosition: SuggestionBasic.CursorPosition,
tabSize: Int,
indentSize: Int,
usesTabsForIndentation: Bool
- ) async throws -> [SuggestionModel.CodeSuggestion] {
+ ) async throws -> [SuggestionBasic.CodeSuggestion] {
completions
}
diff --git a/Core/Tests/ServiceTests/ExtractSelectedCodeTests.swift b/Core/Tests/ServiceTests/ExtractSelectedCodeTests.swift
index 31dea382..c5bd977c 100644
--- a/Core/Tests/ServiceTests/ExtractSelectedCodeTests.swift
+++ b/Core/Tests/ServiceTests/ExtractSelectedCodeTests.swift
@@ -1,4 +1,4 @@
-import SuggestionModel
+import SuggestionBasic
import XCTest
@testable import Service
@testable import XPCShared
diff --git a/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift b/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift
index 26841b41..44ae7129 100644
--- a/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift
+++ b/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift
@@ -1,5 +1,5 @@
import Foundation
-import SuggestionModel
+import SuggestionBasic
import XCTest
@testable import Service
@@ -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/DEVELOPMENT.md b/DEVELOPMENT.md
index deb2c132..5b663fbe 100644
--- a/DEVELOPMENT.md
+++ b/DEVELOPMENT.md
@@ -29,12 +29,13 @@ Most of the logics are implemented inside the package `Core` and `Tool`.
1. Update the xcconfig files, bridgeLaunchAgent.plist, and Tool/Configs/Configurations.swift.
2. Build or archive the Copilot for Xcode target.
-3. If Xcode complains that the pro package doesn't exist, please remove the package from the project.
-## Testing Source Editor Extension
+## Testing Source Editor Extension and Service
Just run both the `ExtensionService`, `CommunicationBridge` and the `EditorExtension` Target. Read [Testing Your Source Editor Extension](https://developer.apple.com/documentation/xcodekit/testing_your_source_editor_extension) for more details.
+If you are not testing the source editor extension, it's recommended to archive and install a debug version of the Copilot for Xcode and test with the bundled source editor extension.
+
## SwiftUI Previews
Looks like SwiftUI Previews are not very happy with Objective-C packages when running with app targets. To use previews, please switch schemes to the package product targets.
diff --git a/EditorExtension/AcceptPromptToCodeCommand.swift b/EditorExtension/AcceptPromptToCodeCommand.swift
index f7d7171c..7ac95c35 100644
--- a/EditorExtension/AcceptPromptToCodeCommand.swift
+++ b/EditorExtension/AcceptPromptToCodeCommand.swift
@@ -1,10 +1,10 @@
import Client
import Foundation
-import SuggestionModel
+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 81882ed9..cbca64ed 100644
--- a/EditorExtension/AcceptSuggestionCommand.swift
+++ b/EditorExtension/AcceptSuggestionCommand.swift
@@ -1,6 +1,6 @@
import Client
-import SuggestionModel
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/CloseIdleTabsCommand.swift b/EditorExtension/CloseIdleTabsCommand.swift
index b2dde69a..0e9537ee 100644
--- a/EditorExtension/CloseIdleTabsCommand.swift
+++ b/EditorExtension/CloseIdleTabsCommand.swift
@@ -1,6 +1,6 @@
import Client
import Foundation
-import SuggestionModel
+import SuggestionBasic
import XcodeKit
class CloseIdleTabsCommand: NSObject, XCSourceEditorCommand, CommandType {
diff --git a/EditorExtension/CustomCommand.swift b/EditorExtension/CustomCommand.swift
index 916c0db3..0a43a51d 100644
--- a/EditorExtension/CustomCommand.swift
+++ b/EditorExtension/CustomCommand.swift
@@ -1,6 +1,6 @@
import Client
import Foundation
-import SuggestionModel
+import SuggestionBasic
import XcodeKit
class CustomCommand: NSObject, XCSourceEditorCommand, CommandType {
diff --git a/EditorExtension/GetSuggestionsCommand.swift b/EditorExtension/GetSuggestionsCommand.swift
index f1138270..6be1c417 100644
--- a/EditorExtension/GetSuggestionsCommand.swift
+++ b/EditorExtension/GetSuggestionsCommand.swift
@@ -1,6 +1,6 @@
import Client
import Foundation
-import SuggestionModel
+import SuggestionBasic
import XcodeKit
class GetSuggestionsCommand: NSObject, XCSourceEditorCommand, CommandType {
diff --git a/EditorExtension/Helpers.swift b/EditorExtension/Helpers.swift
index 924927a8..beb49c66 100644
--- a/EditorExtension/Helpers.swift
+++ b/EditorExtension/Helpers.swift
@@ -1,5 +1,5 @@
-import SuggestionModel
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/NextSuggestionCommand.swift b/EditorExtension/NextSuggestionCommand.swift
index 401fdcd3..f07f4017 100644
--- a/EditorExtension/NextSuggestionCommand.swift
+++ b/EditorExtension/NextSuggestionCommand.swift
@@ -1,6 +1,6 @@
import Client
import Foundation
-import SuggestionModel
+import SuggestionBasic
import XcodeKit
class NextSuggestionCommand: NSObject, XCSourceEditorCommand, CommandType {
diff --git a/EditorExtension/OpenChat.swift b/EditorExtension/OpenChat.swift
index 375f58a2..fccdc3fe 100644
--- a/EditorExtension/OpenChat.swift
+++ b/EditorExtension/OpenChat.swift
@@ -1,5 +1,5 @@
import Client
-import SuggestionModel
+import SuggestionBasic
import Foundation
import XcodeKit
diff --git a/EditorExtension/PrefetchSuggestionsCommand.swift b/EditorExtension/PrefetchSuggestionsCommand.swift
index 73878fbb..bc43c40e 100644
--- a/EditorExtension/PrefetchSuggestionsCommand.swift
+++ b/EditorExtension/PrefetchSuggestionsCommand.swift
@@ -1,5 +1,5 @@
import Client
-import SuggestionModel
+import SuggestionBasic
import Foundation
import XcodeKit
diff --git a/EditorExtension/PreviousSuggestionCommand.swift b/EditorExtension/PreviousSuggestionCommand.swift
index e2b1a47e..61894bab 100644
--- a/EditorExtension/PreviousSuggestionCommand.swift
+++ b/EditorExtension/PreviousSuggestionCommand.swift
@@ -1,6 +1,6 @@
import Client
import Foundation
-import SuggestionModel
+import SuggestionBasic
import XcodeKit
class PreviousSuggestionCommand: NSObject, XCSourceEditorCommand, CommandType {
diff --git a/EditorExtension/PromptToCodeCommand.swift b/EditorExtension/PromptToCodeCommand.swift
index 852bedb6..a2f814ac 100644
--- a/EditorExtension/PromptToCodeCommand.swift
+++ b/EditorExtension/PromptToCodeCommand.swift
@@ -1,10 +1,10 @@
import Client
-import SuggestionModel
+import SuggestionBasic
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/RealtimeSuggestionCommand.swift b/EditorExtension/RealtimeSuggestionCommand.swift
index b2da0296..ed0473d5 100644
--- a/EditorExtension/RealtimeSuggestionCommand.swift
+++ b/EditorExtension/RealtimeSuggestionCommand.swift
@@ -1,5 +1,5 @@
import Client
-import SuggestionModel
+import SuggestionBasic
import Foundation
import XcodeKit
diff --git a/EditorExtension/RejectSuggestionCommand.swift b/EditorExtension/RejectSuggestionCommand.swift
index c19dcf5a..f9f370c9 100644
--- a/EditorExtension/RejectSuggestionCommand.swift
+++ b/EditorExtension/RejectSuggestionCommand.swift
@@ -1,6 +1,6 @@
import Client
import Foundation
-import SuggestionModel
+import SuggestionBasic
import XcodeKit
class RejectSuggestionCommand: NSObject, XCSourceEditorCommand, CommandType {
diff --git a/EditorExtension/SeparatorCommand.swift b/EditorExtension/SeparatorCommand.swift
index ba1ff882..79e4b138 100644
--- a/EditorExtension/SeparatorCommand.swift
+++ b/EditorExtension/SeparatorCommand.swift
@@ -1,5 +1,5 @@
import Client
-import SuggestionModel
+import SuggestionBasic
import Foundation
import XcodeKit
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/EditorExtension/ToggleRealtimeSuggestionsCommand.swift b/EditorExtension/ToggleRealtimeSuggestionsCommand.swift
index 44fe1883..ab226a0b 100644
--- a/EditorExtension/ToggleRealtimeSuggestionsCommand.swift
+++ b/EditorExtension/ToggleRealtimeSuggestionsCommand.swift
@@ -1,5 +1,5 @@
import Client
-import SuggestionModel
+import SuggestionBasic
import Foundation
import XcodeKit
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/LICENSE b/LICENSE
index bdeb59bb..636cb078 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,691 +1,21 @@
-# Copilot for Xcode Open Source License
-
-This license is a combination of the GPLv3 and some additional agreements.
-
-Features that requires a Plus license key are not included in this project, and are not open source.
-
-As a contributor, you agree that your contributed code:
-a. may be subject to a more permissive open-source license in the future.
-b. can be used for commercial purposes.
-
-With the GPLv3 and these supplementary agreements, anyone can freely use, modify, and distribute the project, provided that:
-- For commercial redistribution or commercial forks of this project, please contact us for authorization.
-
-Copyright (c) 2023 Shangxin Guo
-
-----------
-
- GNU GENERAL PUBLIC LICENSE
- Version 3, 29 June 2007
-
- Copyright (C) 2007 Free Software Foundation, Inc.
- Everyone is permitted to copy and distribute verbatim copies
- of this license document, but changing it is not allowed.
-
- Preamble
-
- The GNU General Public License is a free, copyleft license for
-software and other kinds of works.
-
- The licenses for most software and other practical works are designed
-to take away your freedom to share and change the works. By contrast,
-the GNU General Public License is intended to guarantee your freedom to
-share and change all versions of a program--to make sure it remains free
-software for all its users. We, the Free Software Foundation, use the
-GNU General Public License for most of our software; it applies also to
-any other work released this way by its authors. You can apply it to
-your programs, too.
-
- When we speak of free software, we are referring to freedom, not
-price. Our General Public Licenses are designed to make sure that you
-have the freedom to distribute copies of free software (and charge for
-them if you wish), that you receive source code or can get it if you
-want it, that you can change the software or use pieces of it in new
-free programs, and that you know you can do these things.
-
- To protect your rights, we need to prevent others from denying you
-these rights or asking you to surrender the rights. Therefore, you have
-certain responsibilities if you distribute copies of the software, or if
-you modify it: responsibilities to respect the freedom of others.
-
- For example, if you distribute copies of such a program, whether
-gratis or for a fee, you must pass on to the recipients the same
-freedoms that you received. You must make sure that they, too, receive
-or can get the source code. And you must show them these terms so they
-know their rights.
-
- Developers that use the GNU GPL protect your rights with two steps:
-(1) assert copyright on the software, and (2) offer you this License
-giving you legal permission to copy, distribute and/or modify it.
-
- For the developers' and authors' protection, the GPL clearly explains
-that there is no warranty for this free software. For both users' and
-authors' sake, the GPL requires that modified versions be marked as
-changed, so that their problems will not be attributed erroneously to
-authors of previous versions.
-
- Some devices are designed to deny users access to install or run
-modified versions of the software inside them, although the manufacturer
-can do so. This is fundamentally incompatible with the aim of
-protecting users' freedom to change the software. The systematic
-pattern of such abuse occurs in the area of products for individuals to
-use, which is precisely where it is most unacceptable. Therefore, we
-have designed this version of the GPL to prohibit the practice for those
-products. If such problems arise substantially in other domains, we
-stand ready to extend this provision to those domains in future versions
-of the GPL, as needed to protect the freedom of users.
-
- Finally, every program is threatened constantly by software patents.
-States should not allow patents to restrict development and use of
-software on general-purpose computers, but in those that do, we wish to
-avoid the special danger that patents applied to a free program could
-make it effectively proprietary. To prevent this, the GPL assures that
-patents cannot be used to render the program non-free.
-
- The precise terms and conditions for copying, distribution and
-modification follow.
-
- TERMS AND CONDITIONS
-
- 0. Definitions.
-
- "This License" refers to version 3 of the GNU General Public License.
-
- "Copyright" also means copyright-like laws that apply to other kinds of
-works, such as semiconductor masks.
-
- "The Program" refers to any copyrightable work licensed under this
-License. Each licensee is addressed as "you". "Licensees" and
-"recipients" may be individuals or organizations.
-
- To "modify" a work means to copy from or adapt all or part of the work
-in a fashion requiring copyright permission, other than the making of an
-exact copy. The resulting work is called a "modified version" of the
-earlier work or a work "based on" the earlier work.
-
- A "covered work" means either the unmodified Program or a work based
-on the Program.
-
- To "propagate" a work means to do anything with it that, without
-permission, would make you directly or secondarily liable for
-infringement under applicable copyright law, except executing it on a
-computer or modifying a private copy. Propagation includes copying,
-distribution (with or without modification), making available to the
-public, and in some countries other activities as well.
-
- To "convey" a work means any kind of propagation that enables other
-parties to make or receive copies. Mere interaction with a user through
-a computer network, with no transfer of a copy, is not conveying.
-
- An interactive user interface displays "Appropriate Legal Notices"
-to the extent that it includes a convenient and prominently visible
-feature that (1) displays an appropriate copyright notice, and (2)
-tells the user that there is no warranty for the work (except to the
-extent that warranties are provided), that licensees may convey the
-work under this License, and how to view a copy of this License. If
-the interface presents a list of user commands or options, such as a
-menu, a prominent item in the list meets this criterion.
-
- 1. Source Code.
-
- The "source code" for a work means the preferred form of the work
-for making modifications to it. "Object code" means any non-source
-form of a work.
-
- A "Standard Interface" means an interface that either is an official
-standard defined by a recognized standards body, or, in the case of
-interfaces specified for a particular programming language, one that
-is widely used among developers working in that language.
-
- The "System Libraries" of an executable work include anything, other
-than the work as a whole, that (a) is included in the normal form of
-packaging a Major Component, but which is not part of that Major
-Component, and (b) serves only to enable use of the work with that
-Major Component, or to implement a Standard Interface for which an
-implementation is available to the public in source code form. A
-"Major Component", in this context, means a major essential component
-(kernel, window system, and so on) of the specific operating system
-(if any) on which the executable work runs, or a compiler used to
-produce the work, or an object code interpreter used to run it.
-
- The "Corresponding Source" for a work in object code form means all
-the source code needed to generate, install, and (for an executable
-work) run the object code and to modify the work, including scripts to
-control those activities. However, it does not include the work's
-System Libraries, or general-purpose tools or generally available free
-programs which are used unmodified in performing those activities but
-which are not part of the work. For example, Corresponding Source
-includes interface definition files associated with source files for
-the work, and the source code for shared libraries and dynamically
-linked subprograms that the work is specifically designed to require,
-such as by intimate data communication or control flow between those
-subprograms and other parts of the work.
-
- The Corresponding Source need not include anything that users
-can regenerate automatically from other parts of the Corresponding
-Source.
-
- The Corresponding Source for a work in source code form is that
-same work.
-
- 2. Basic Permissions.
-
- All rights granted under this License are granted for the term of
-copyright on the Program, and are irrevocable provided the stated
-conditions are met. This License explicitly affirms your unlimited
-permission to run the unmodified Program. The output from running a
-covered work is covered by this License only if the output, given its
-content, constitutes a covered work. This License acknowledges your
-rights of fair use or other equivalent, as provided by copyright law.
-
- You may make, run and propagate covered works that you do not
-convey, without conditions so long as your license otherwise remains
-in force. You may convey covered works to others for the sole purpose
-of having them make modifications exclusively for you, or provide you
-with facilities for running those works, provided that you comply with
-the terms of this License in conveying all material for which you do
-not control copyright. Those thus making or running the covered works
-for you must do so exclusively on your behalf, under your direction
-and control, on terms that prohibit them from making any copies of
-your copyrighted material outside their relationship with you.
-
- Conveying under any other circumstances is permitted solely under
-the conditions stated below. Sublicensing is not allowed; section 10
-makes it unnecessary.
-
- 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
-
- No covered work shall be deemed part of an effective technological
-measure under any applicable law fulfilling obligations under article
-11 of the WIPO copyright treaty adopted on 20 December 1996, or
-similar laws prohibiting or restricting circumvention of such
-measures.
-
- When you convey a covered work, you waive any legal power to forbid
-circumvention of technological measures to the extent such circumvention
-is effected by exercising rights under this License with respect to
-the covered work, and you disclaim any intention to limit operation or
-modification of the work as a means of enforcing, against the work's
-users, your or third parties' legal rights to forbid circumvention of
-technological measures.
-
- 4. Conveying Verbatim Copies.
-
- You may convey verbatim copies of the Program's source code as you
-receive it, in any medium, provided that you conspicuously and
-appropriately publish on each copy an appropriate copyright notice;
-keep intact all notices stating that this License and any
-non-permissive terms added in accord with section 7 apply to the code;
-keep intact all notices of the absence of any warranty; and give all
-recipients a copy of this License along with the Program.
-
- You may charge any price or no price for each copy that you convey,
-and you may offer support or warranty protection for a fee.
-
- 5. Conveying Modified Source Versions.
-
- You may convey a work based on the Program, or the modifications to
-produce it from the Program, in the form of source code under the
-terms of section 4, provided that you also meet all of these conditions:
-
- a) The work must carry prominent notices stating that you modified
- it, and giving a relevant date.
-
- b) The work must carry prominent notices stating that it is
- released under this License and any conditions added under section
- 7. This requirement modifies the requirement in section 4 to
- "keep intact all notices".
-
- c) You must license the entire work, as a whole, under this
- License to anyone who comes into possession of a copy. This
- License will therefore apply, along with any applicable section 7
- additional terms, to the whole of the work, and all its parts,
- regardless of how they are packaged. This License gives no
- permission to license the work in any other way, but it does not
- invalidate such permission if you have separately received it.
-
- d) If the work has interactive user interfaces, each must display
- Appropriate Legal Notices; however, if the Program has interactive
- interfaces that do not display Appropriate Legal Notices, your
- work need not make them do so.
-
- A compilation of a covered work with other separate and independent
-works, which are not by their nature extensions of the covered work,
-and which are not combined with it such as to form a larger program,
-in or on a volume of a storage or distribution medium, is called an
-"aggregate" if the compilation and its resulting copyright are not
-used to limit the access or legal rights of the compilation's users
-beyond what the individual works permit. Inclusion of a covered work
-in an aggregate does not cause this License to apply to the other
-parts of the aggregate.
-
- 6. Conveying Non-Source Forms.
-
- You may convey a covered work in object code form under the terms
-of sections 4 and 5, provided that you also convey the
-machine-readable Corresponding Source under the terms of this License,
-in one of these ways:
-
- a) Convey the object code in, or embodied in, a physical product
- (including a physical distribution medium), accompanied by the
- Corresponding Source fixed on a durable physical medium
- customarily used for software interchange.
-
- b) Convey the object code in, or embodied in, a physical product
- (including a physical distribution medium), accompanied by a
- written offer, valid for at least three years and valid for as
- long as you offer spare parts or customer support for that product
- model, to give anyone who possesses the object code either (1) a
- copy of the Corresponding Source for all the software in the
- product that is covered by this License, on a durable physical
- medium customarily used for software interchange, for a price no
- more than your reasonable cost of physically performing this
- conveying of source, or (2) access to copy the
- Corresponding Source from a network server at no charge.
-
- c) Convey individual copies of the object code with a copy of the
- written offer to provide the Corresponding Source. This
- alternative is allowed only occasionally and noncommercially, and
- only if you received the object code with such an offer, in accord
- with subsection 6b.
-
- d) Convey the object code by offering access from a designated
- place (gratis or for a charge), and offer equivalent access to the
- Corresponding Source in the same way through the same place at no
- further charge. You need not require recipients to copy the
- Corresponding Source along with the object code. If the place to
- copy the object code is a network server, the Corresponding Source
- may be on a different server (operated by you or a third party)
- that supports equivalent copying facilities, provided you maintain
- clear directions next to the object code saying where to find the
- Corresponding Source. Regardless of what server hosts the
- Corresponding Source, you remain obligated to ensure that it is
- available for as long as needed to satisfy these requirements.
-
- e) Convey the object code using peer-to-peer transmission, provided
- you inform other peers where the object code and Corresponding
- Source of the work are being offered to the general public at no
- charge under subsection 6d.
-
- A separable portion of the object code, whose source code is excluded
-from the Corresponding Source as a System Library, need not be
-included in conveying the object code work.
-
- A "User Product" is either (1) a "consumer product", which means any
-tangible personal property which is normally used for personal, family,
-or household purposes, or (2) anything designed or sold for incorporation
-into a dwelling. In determining whether a product is a consumer product,
-doubtful cases shall be resolved in favor of coverage. For a particular
-product received by a particular user, "normally used" refers to a
-typical or common use of that class of product, regardless of the status
-of the particular user or of the way in which the particular user
-actually uses, or expects or is expected to use, the product. A product
-is a consumer product regardless of whether the product has substantial
-commercial, industrial or non-consumer uses, unless such uses represent
-the only significant mode of use of the product.
-
- "Installation Information" for a User Product means any methods,
-procedures, authorization keys, or other information required to install
-and execute modified versions of a covered work in that User Product from
-a modified version of its Corresponding Source. The information must
-suffice to ensure that the continued functioning of the modified object
-code is in no case prevented or interfered with solely because
-modification has been made.
-
- If you convey an object code work under this section in, or with, or
-specifically for use in, a User Product, and the conveying occurs as
-part of a transaction in which the right of possession and use of the
-User Product is transferred to the recipient in perpetuity or for a
-fixed term (regardless of how the transaction is characterized), the
-Corresponding Source conveyed under this section must be accompanied
-by the Installation Information. But this requirement does not apply
-if neither you nor any third party retains the ability to install
-modified object code on the User Product (for example, the work has
-been installed in ROM).
-
- The requirement to provide Installation Information does not include a
-requirement to continue to provide support service, warranty, or updates
-for a work that has been modified or installed by the recipient, or for
-the User Product in which it has been modified or installed. Access to a
-network may be denied when the modification itself materially and
-adversely affects the operation of the network or violates the rules and
-protocols for communication across the network.
-
- Corresponding Source conveyed, and Installation Information provided,
-in accord with this section must be in a format that is publicly
-documented (and with an implementation available to the public in
-source code form), and must require no special password or key for
-unpacking, reading or copying.
-
- 7. Additional Terms.
-
- "Additional permissions" are terms that supplement the terms of this
-License by making exceptions from one or more of its conditions.
-Additional permissions that are applicable to the entire Program shall
-be treated as though they were included in this License, to the extent
-that they are valid under applicable law. If additional permissions
-apply only to part of the Program, that part may be used separately
-under those permissions, but the entire Program remains governed by
-this License without regard to the additional permissions.
-
- When you convey a copy of a covered work, you may at your option
-remove any additional permissions from that copy, or from any part of
-it. (Additional permissions may be written to require their own
-removal in certain cases when you modify the work.) You may place
-additional permissions on material, added by you to a covered work,
-for which you have or can give appropriate copyright permission.
-
- Notwithstanding any other provision of this License, for material you
-add to a covered work, you may (if authorized by the copyright holders of
-that material) supplement the terms of this License with terms:
-
- a) Disclaiming warranty or limiting liability differently from the
- terms of sections 15 and 16 of this License; or
-
- b) Requiring preservation of specified reasonable legal notices or
- author attributions in that material or in the Appropriate Legal
- Notices displayed by works containing it; or
-
- c) Prohibiting misrepresentation of the origin of that material, or
- requiring that modified versions of such material be marked in
- reasonable ways as different from the original version; or
-
- d) Limiting the use for publicity purposes of names of licensors or
- authors of the material; or
-
- e) Declining to grant rights under trademark law for use of some
- trade names, trademarks, or service marks; or
-
- f) Requiring indemnification of licensors and authors of that
- material by anyone who conveys the material (or modified versions of
- it) with contractual assumptions of liability to the recipient, for
- any liability that these contractual assumptions directly impose on
- those licensors and authors.
-
- All other non-permissive additional terms are considered "further
-restrictions" within the meaning of section 10. If the Program as you
-received it, or any part of it, contains a notice stating that it is
-governed by this License along with a term that is a further
-restriction, you may remove that term. If a license document contains
-a further restriction but permits relicensing or conveying under this
-License, you may add to a covered work material governed by the terms
-of that license document, provided that the further restriction does
-not survive such relicensing or conveying.
-
- If you add terms to a covered work in accord with this section, you
-must place, in the relevant source files, a statement of the
-additional terms that apply to those files, or a notice indicating
-where to find the applicable terms.
-
- Additional terms, permissive or non-permissive, may be stated in the
-form of a separately written license, or stated as exceptions;
-the above requirements apply either way.
-
- 8. Termination.
-
- You may not propagate or modify a covered work except as expressly
-provided under this License. Any attempt otherwise to propagate or
-modify it is void, and will automatically terminate your rights under
-this License (including any patent licenses granted under the third
-paragraph of section 11).
-
- However, if you cease all violation of this License, then your
-license from a particular copyright holder is reinstated (a)
-provisionally, unless and until the copyright holder explicitly and
-finally terminates your license, and (b) permanently, if the copyright
-holder fails to notify you of the violation by some reasonable means
-prior to 60 days after the cessation.
-
- Moreover, your license from a particular copyright holder is
-reinstated permanently if the copyright holder notifies you of the
-violation by some reasonable means, this is the first time you have
-received notice of violation of this License (for any work) from that
-copyright holder, and you cure the violation prior to 30 days after
-your receipt of the notice.
-
- Termination of your rights under this section does not terminate the
-licenses of parties who have received copies or rights from you under
-this License. If your rights have been terminated and not permanently
-reinstated, you do not qualify to receive new licenses for the same
-material under section 10.
-
- 9. Acceptance Not Required for Having Copies.
-
- You are not required to accept this License in order to receive or
-run a copy of the Program. Ancillary propagation of a covered work
-occurring solely as a consequence of using peer-to-peer transmission
-to receive a copy likewise does not require acceptance. However,
-nothing other than this License grants you permission to propagate or
-modify any covered work. These actions infringe copyright if you do
-not accept this License. Therefore, by modifying or propagating a
-covered work, you indicate your acceptance of this License to do so.
-
- 10. Automatic Licensing of Downstream Recipients.
-
- Each time you convey a covered work, the recipient automatically
-receives a license from the original licensors, to run, modify and
-propagate that work, subject to this License. You are not responsible
-for enforcing compliance by third parties with this License.
-
- An "entity transaction" is a transaction transferring control of an
-organization, or substantially all assets of one, or subdividing an
-organization, or merging organizations. If propagation of a covered
-work results from an entity transaction, each party to that
-transaction who receives a copy of the work also receives whatever
-licenses to the work the party's predecessor in interest had or could
-give under the previous paragraph, plus a right to possession of the
-Corresponding Source of the work from the predecessor in interest, if
-the predecessor has it or can get it with reasonable efforts.
-
- You may not impose any further restrictions on the exercise of the
-rights granted or affirmed under this License. For example, you may
-not impose a license fee, royalty, or other charge for exercise of
-rights granted under this License, and you may not initiate litigation
-(including a cross-claim or counterclaim in a lawsuit) alleging that
-any patent claim is infringed by making, using, selling, offering for
-sale, or importing the Program or any portion of it.
-
- 11. Patents.
-
- A "contributor" is a copyright holder who authorizes use under this
-License of the Program or a work on which the Program is based. The
-work thus licensed is called the contributor's "contributor version".
-
- A contributor's "essential patent claims" are all patent claims
-owned or controlled by the contributor, whether already acquired or
-hereafter acquired, that would be infringed by some manner, permitted
-by this License, of making, using, or selling its contributor version,
-but do not include claims that would be infringed only as a
-consequence of further modification of the contributor version. For
-purposes of this definition, "control" includes the right to grant
-patent sublicenses in a manner consistent with the requirements of
-this License.
-
- Each contributor grants you a non-exclusive, worldwide, royalty-free
-patent license under the contributor's essential patent claims, to
-make, use, sell, offer for sale, import and otherwise run, modify and
-propagate the contents of its contributor version.
-
- In the following three paragraphs, a "patent license" is any express
-agreement or commitment, however denominated, not to enforce a patent
-(such as an express permission to practice a patent or covenant not to
-sue for patent infringement). To "grant" such a patent license to a
-party means to make such an agreement or commitment not to enforce a
-patent against the party.
-
- If you convey a covered work, knowingly relying on a patent license,
-and the Corresponding Source of the work is not available for anyone
-to copy, free of charge and under the terms of this License, through a
-publicly available network server or other readily accessible means,
-then you must either (1) cause the Corresponding Source to be so
-available, or (2) arrange to deprive yourself of the benefit of the
-patent license for this particular work, or (3) arrange, in a manner
-consistent with the requirements of this License, to extend the patent
-license to downstream recipients. "Knowingly relying" means you have
-actual knowledge that, but for the patent license, your conveying the
-covered work in a country, or your recipient's use of the covered work
-in a country, would infringe one or more identifiable patents in that
-country that you have reason to believe are valid.
-
- If, pursuant to or in connection with a single transaction or
-arrangement, you convey, or propagate by procuring conveyance of, a
-covered work, and grant a patent license to some of the parties
-receiving the covered work authorizing them to use, propagate, modify
-or convey a specific copy of the covered work, then the patent license
-you grant is automatically extended to all recipients of the covered
-work and works based on it.
-
- A patent license is "discriminatory" if it does not include within
-the scope of its coverage, prohibits the exercise of, or is
-conditioned on the non-exercise of one or more of the rights that are
-specifically granted under this License. You may not convey a covered
-work if you are a party to an arrangement with a third party that is
-in the business of distributing software, under which you make payment
-to the third party based on the extent of your activity of conveying
-the work, and under which the third party grants, to any of the
-parties who would receive the covered work from you, a discriminatory
-patent license (a) in connection with copies of the covered work
-conveyed by you (or copies made from those copies), or (b) primarily
-for and in connection with specific products or compilations that
-contain the covered work, unless you entered into that arrangement,
-or that patent license was granted, prior to 28 March 2007.
-
- Nothing in this License shall be construed as excluding or limiting
-any implied license or other defenses to infringement that may
-otherwise be available to you under applicable patent law.
-
- 12. No Surrender of Others' Freedom.
-
- If conditions are imposed on you (whether by court order, agreement or
-otherwise) that contradict the conditions of this License, they do not
-excuse you from the conditions of this License. If you cannot convey a
-covered work so as to satisfy simultaneously your obligations under this
-License and any other pertinent obligations, then as a consequence you may
-not convey it at all. For example, if you agree to terms that obligate you
-to collect a royalty for further conveying from those to whom you convey
-the Program, the only way you could satisfy both those terms and this
-License would be to refrain entirely from conveying the Program.
-
- 13. Use with the GNU Affero General Public License.
-
- Notwithstanding any other provision of this License, you have
-permission to link or combine any covered work with a work licensed
-under version 3 of the GNU Affero General Public License into a single
-combined work, and to convey the resulting work. The terms of this
-License will continue to apply to the part which is the covered work,
-but the special requirements of the GNU Affero General Public License,
-section 13, concerning interaction through a network will apply to the
-combination as such.
-
- 14. Revised Versions of this License.
-
- The Free Software Foundation may publish revised and/or new versions of
-the GNU General Public License from time to time. Such new versions will
-be similar in spirit to the present version, but may differ in detail to
-address new problems or concerns.
-
- Each version is given a distinguishing version number. If the
-Program specifies that a certain numbered version of the GNU General
-Public License "or any later version" applies to it, you have the
-option of following the terms and conditions either of that numbered
-version or of any later version published by the Free Software
-Foundation. If the Program does not specify a version number of the
-GNU General Public License, you may choose any version ever published
-by the Free Software Foundation.
-
- If the Program specifies that a proxy can decide which future
-versions of the GNU General Public License can be used, that proxy's
-public statement of acceptance of a version permanently authorizes you
-to choose that version for the Program.
-
- Later license versions may give you additional or different
-permissions. However, no additional obligations are imposed on any
-author or copyright holder as a result of your choosing to follow a
-later version.
-
- 15. Disclaimer of Warranty.
-
- THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
-APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
-HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
-OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
-THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
-PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
-IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
-ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
-
- 16. Limitation of Liability.
-
- IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
-WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
-THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
-GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
-USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
-DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
-PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
-EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
-SUCH DAMAGES.
-
- 17. Interpretation of Sections 15 and 16.
-
- If the disclaimer of warranty and limitation of liability provided
-above cannot be given local legal effect according to their terms,
-reviewing courts shall apply local law that most closely approximates
-an absolute waiver of all civil liability in connection with the
-Program, unless a warranty or assumption of liability accompanies a
-copy of the Program in return for a fee.
-
- END OF TERMS AND CONDITIONS
-
- How to Apply These Terms to Your New Programs
-
- If you develop a new program, and you want it to be of the greatest
-possible use to the public, the best way to achieve this is to make it
-free software which everyone can redistribute and change under these terms.
-
- To do so, attach the following notices to the program. It is safest
-to attach them to the start of each source file to most effectively
-state the exclusion of warranty; and each file should have at least
-the "copyright" line and a pointer to where the full notice is found.
-
-
- Copyright (C)
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License
- along with this program. If not, see .
-
-Also add information on how to contact you by electronic and paper mail.
-
- If the program does terminal interaction, make it output a short
-notice like this when it starts in an interactive mode:
-
- Copyright (C)
- This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
- This is free software, and you are welcome to redistribute it
- under certain conditions; type `show c' for details.
-
-The hypothetical commands `show w' and `show c' should show the appropriate
-parts of the General Public License. Of course, your program's commands
-might be different; for a GUI interface, you would use an "about box".
-
- You should also get your employer (if you work as a programmer) or school,
-if any, to sign a "copyright disclaimer" for the program, if necessary.
-For more information on this, and how to apply and follow the GNU GPL, see
-.
-
- The GNU General Public License does not permit incorporating your program
-into proprietary programs. If your program is a subroutine library, you
-may consider it more useful to permit linking proprietary applications with
-the library. If this is what you want to do, use the GNU Lesser General
-Public License instead of this License. But first, please read
-.
+MIT License
+
+Copyright (c) 2024 Shangxin Guo
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/Makefile b/Makefile
deleted file mode 100644
index b16f8417..00000000
--- a/Makefile
+++ /dev/null
@@ -1,25 +0,0 @@
-GITHUB_URL := https://github.com/intitni/CopilotForXcode/
-ZIPNAME_BASE := Copilot.for.Xcode.app
-
-setup:
- echo "Setup."
-
-# Usage: make appcast app=path/to/bundle.app tag=1.0.0 [channel=beta] [release=1]
-appcast:
- $(eval TMPDIR := ~/Library/Caches/CopilotForXcodeRelease/$(shell uuidgen))
- $(eval BUNDLENAME := $(shell basename "$(app)"))
- $(eval WORKDIR := $(shell dirname "$(app)"))
- $(eval ZIPNAME := $(ZIPNAME_BASE)$(if $(channel),.$(channel).$(if $(release),$(release),1)))
- $(eval RELEASENOTELINK := $(GITHUB_URL)releases/tag/$(tag))
- mkdir -p $(TMPDIR)
- cp appcast.xml $(TMPDIR)/appcast.xml
- cd $(WORKDIR) && ditto -c -k --sequesterRsrc --keepParent "$(BUNDLENAME)" "$(ZIPNAME).zip"
- cd $(WORKDIR) && cp "$(ZIPNAME).zip" $(TMPDIR)/
- touch $(TMPDIR)/$(ZIPNAME).html
- echo "" > $(TMPDIR)/$(ZIPNAME).html
- -Core/.build/artifacts/sparkle/bin/generate_appcast $(TMPDIR) --download-url-prefix "$(GITHUB_URL)releases/download/$(tag)/" --release-notes-url-prefix "$(RELEASENOTELINK)" $(if $(channel),--channel "$(channel)")
- mv -f $(TMPDIR)/appcast.xml .
- rm -rf $(TMPDIR)
- sed -i '' 's/$(ZIPNAME).html/$(tag)/g' appcast.xml
-
-.PHONY: setup appcast
\ No newline at end of file
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/Pro b/Pro
deleted file mode 160000
index d58fa18d..00000000
--- a/Pro
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit d58fa18d0bd388aaa54f13ae4aad9853221bff1f
diff --git a/README.md b/README.md
index f162bdad..c4066a45 100644
--- a/README.md
+++ b/README.md
@@ -6,41 +6,51 @@ Copilot for Xcode is an Xcode Source Editor Extension that provides GitHub Copil
-[Get a Plus License Key to unlock more features and support this project](https://intii.lemonsqueezy.com)
-
## 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)
-- [Plus Features](#plus-features)
-- [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
@@ -95,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
@@ -121,7 +136,8 @@ A [recommended setup](https://github.com/intitni/CopilotForXcode/issues/14) that
| Command | Key Binding |
| ------------------- | ------------------------------------------------------ |
-| Accept Suggestions | `⌥}` (Or accept with Tab if Plus license is available) |
+| Accept Suggestions | `⌥}` or Tab |
+| Dismiss Suggestions | Esc |
| Reject Suggestion | `⌥{` |
| Next Suggestion | `⌥>` |
| Previous Suggestion | `⌥<` |
@@ -134,7 +150,7 @@ Another convenient method to access commands is by using the `⇧⌘/` shortcut
#### Setting Up Global Hotkeys
-Currently, the is only one global hotkey you can set to show/hide the widgets under the General tab from the host app.
+Currently, there is only one global hotkey you can set to show/hide the widgets under the General tab from the host app.
When this hotkey is not set to enabled globally, it will only work when the service app or Xcode is active.
@@ -167,15 +183,6 @@ The installed language server is located at `~/Library/Application Support/com.i
The installed language server is located at `~/Library/Application Support/com.intii.CopilotForXcode/Codeium/executable/`.
-#### Using Locally Run LLMs
-
-You can also use locally run LLMs or as a suggestion provider using the [Custom Suggestion Service](https://github.com/intitni/CustomSuggestionServiceForCopilotForXcode) extension. It supports:
-
-- LLM with OpenAI compatible completions API
-- LLM with OpenAI compatible chat completions API
-- [Tabby](https://tabby.tabbyml.com)
-- etc.
-
### Setting Up Chat Feature
1. In the host app, navigate to "Service - Chat Model".
@@ -208,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.
@@ -244,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
@@ -255,32 +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. |
-| `⇧↩︎` | Add new line. |
+| `⌘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`.
+#### Chat Commands
-| Scope | Description |
-| :--------: | ---------------------------------------------------------------------------------------- |
-| `@file` | Includes the metadata of the editing document and line annotations in the system prompt. |
-| `@code` | Includes the focused/selected code and everything from `@file` in the system prompt. |
-| `@sense` | Experimental. Read the relevant information of the focused code |
-| `@project` | Experimental. Access content of the project |
-| `@web` | Allow the bot to search on Bing or query from a web page |
-
-`@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 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
@@ -294,14 +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. |
-| `/math` | Solves a math problem in natural language |
-| `/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
@@ -317,28 +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`.
-
-| Scope | Description |
-| :--------: | ---------------------------------------------------------------------------------------- |
-| `@sense` | Experimental. Read the relevant information of the focused code |
-
-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`.
@@ -353,34 +326,6 @@ You can use the following template arguments in custom commands:
| `{{active_editor_file_name}}` | The name of the active file in the editor. |
| `{{clipboard}}` | The content in clipboard. |
-## Plus Features
-
-The pre-built binary contains a set of exclusive features that can only be accessed with a Plus license key. To obtain a license key, please visit [this link](https://intii.lemonsqueezy.com).
-
-These features are included in another repo, and are not open sourced.
-
-The currently available Plus features include:
-
-- `@project` scope in chat to include project information in conversations. (experimental)
-- Suggestion Cheatsheet that provides relevant content to the suggestion service. (experimental)
-- `@sense` scope in chat and prompt to code to include relevant information of the focusing code.
-- Terminal tab in chat panel.
-- Unlimited chat/embedding models.
-- Tab to accept suggestions.
-- Persisted chat panel.
-- Browser tab in chat panel.
-- Unlimited custom commands.
-
-Since the app needs to manage license keys, it will send network request to `https://copilotforxcode-license.intii.com`,
-
-- when you activate the license key
-- when you deactivate the license key
-- when you validate the license key manually
-- when you open the host app or the service app if a license key is available
-- every 24 hours if a license key is available
-
-The request contains only the license key, the email address (only on activation), and an instance id. You are free to MITM the request to see what data is sent.
-
## Limitations
- The extension utilizes various tricks to monitor the state of Xcode. It may fail, it may be incorrect, especially when you have multiple Xcode windows running, and maybe even worse when they are in different displays. I am not sure about that though.
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 cefad736..c9ebe525 100644
--- a/TestPlan.xctestplan
+++ b/TestPlan.xctestplan
@@ -24,23 +24,44 @@
"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:Tool",
+ "identifier" : "CodeDiffTests",
+ "name" : "CodeDiffTests"
}
},
{
"target" : {
"containerPath" : "container:Core",
- "identifier" : "SuggestionWidgetTests",
- "name" : "SuggestionWidgetTests"
+ "identifier" : "ServiceUpdateMigrationTests",
+ "name" : "ServiceUpdateMigrationTests"
+ }
+ },
+ {
+ "target" : {
+ "containerPath" : "container:Tool",
+ "identifier" : "ASTParserTests",
+ "name" : "ASTParserTests"
+ }
+ },
+ {
+ "target" : {
+ "containerPath" : "container:Tool",
+ "identifier" : "SuggestionProviderTests",
+ "name" : "SuggestionProviderTests"
}
},
{
@@ -53,99 +74,113 @@
{
"target" : {
"containerPath" : "container:Tool",
- "identifier" : "LangChainTests",
- "name" : "LangChainTests"
+ "identifier" : "KeychainTests",
+ "name" : "KeychainTests"
}
},
{
"target" : {
"containerPath" : "container:Tool",
- "identifier" : "OpenAIServiceTests",
- "name" : "OpenAIServiceTests"
+ "identifier" : "JoinJSONTests",
+ "name" : "JoinJSONTests"
}
},
{
"target" : {
"containerPath" : "container:Core",
- "identifier" : "ChatServiceTests",
- "name" : "ChatServiceTests"
+ "identifier" : "ServiceTests",
+ "name" : "ServiceTests"
+ }
+ },
+ {
+ "target" : {
+ "containerPath" : "container:OverlayWindow",
+ "identifier" : "OverlayWindowTests",
+ "name" : "OverlayWindowTests"
}
},
{
"target" : {
"containerPath" : "container:Tool",
- "identifier" : "TokenEncoderTests",
- "name" : "TokenEncoderTests"
+ "identifier" : "SuggestionBasicTests",
+ "name" : "SuggestionBasicTests"
}
},
{
"target" : {
"containerPath" : "container:Tool",
- "identifier" : "SuggestionModelTests",
- "name" : "SuggestionModelTests"
+ "identifier" : "LangChainTests",
+ "name" : "LangChainTests"
}
},
{
"target" : {
"containerPath" : "container:Tool",
- "identifier" : "SharedUIComponentsTests",
- "name" : "SharedUIComponentsTests"
+ "identifier" : "GitHubCopilotServiceTests",
+ "name" : "GitHubCopilotServiceTests"
}
},
{
"target" : {
"containerPath" : "container:Tool",
- "identifier" : "ASTParserTests",
- "name" : "ASTParserTests"
+ "identifier" : "OpenAIServiceTests",
+ "name" : "OpenAIServiceTests"
}
},
{
"target" : {
"containerPath" : "container:Core",
- "identifier" : "ServiceUpdateMigrationTests",
- "name" : "ServiceUpdateMigrationTests"
+ "identifier" : "SuggestionWidgetTests",
+ "name" : "SuggestionWidgetTests"
}
},
{
"target" : {
- "containerPath" : "container:Tool",
- "identifier" : "KeychainTests",
- "name" : "KeychainTests"
+ "containerPath" : "container:Core",
+ "identifier" : "KeyBindingManagerTests",
+ "name" : "KeyBindingManagerTests"
}
},
{
"target" : {
"containerPath" : "container:Tool",
- "identifier" : "ActiveDocumentChatContextCollectorTests",
- "name" : "ActiveDocumentChatContextCollectorTests"
+ "identifier" : "FocusedCodeFinderTests",
+ "name" : "FocusedCodeFinderTests"
}
},
{
"target" : {
"containerPath" : "container:Tool",
- "identifier" : "GitHubCopilotServiceTests",
- "name" : "GitHubCopilotServiceTests"
+ "identifier" : "WebSearchServiceTests",
+ "name" : "WebSearchServiceTests"
}
},
{
"target" : {
"containerPath" : "container:Tool",
- "identifier" : "FocusedCodeFinderTests",
- "name" : "FocusedCodeFinderTests"
+ "identifier" : "XcodeInspectorTests",
+ "name" : "XcodeInspectorTests"
+ }
+ },
+ {
+ "target" : {
+ "containerPath" : "container:Core",
+ "identifier" : "ChatServiceTests",
+ "name" : "ChatServiceTests"
}
},
{
"target" : {
"containerPath" : "container:Tool",
- "identifier" : "XcodeInspectorTests",
- "name" : "XcodeInspectorTests"
+ "identifier" : "SuggestionInjectorTests",
+ "name" : "SuggestionInjectorTests"
}
},
{
"target" : {
"containerPath" : "container:Tool",
- "identifier" : "SuggestionProviderTests",
- "name" : "SuggestionProviderTests"
+ "identifier" : "TokenEncoderTests",
+ "name" : "TokenEncoderTests"
}
}
],
diff --git a/Tool/Package.swift b/Tool/Package.swift
index a42040e0..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,14 +20,17 @@ let package = Package(
name: "ChatContextCollector",
targets: ["ChatContextCollector", "ActiveDocumentChatContextCollector"]
),
- .library(name: "SuggestionModel", targets: ["SuggestionModel"]),
+ .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"]),
.library(name: "Keychain", targets: ["Keychain"]),
.library(name: "SharedUIComponents", targets: ["SharedUIComponents"]),
.library(name: "UserDefaultsObserver", targets: ["UserDefaultsObserver"]),
- .library(name: "Workspace", targets: ["Workspace", "WorkspaceSuggestionService"]),
+ .library(name: "Workspace", targets: ["Workspace"]),
+ .library(name: "WorkspaceSuggestionService", targets: ["WorkspaceSuggestionService"]),
.library(
name: "SuggestionProvider",
targets: ["SuggestionProvider", "GitHubCopilotService", "CodeiumService"]
@@ -46,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.
@@ -61,17 +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"),
- .package(url: "https://github.com/krzyzanowskim/STTextView", from: "0.8.21"),
// 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", branch: "develop"),
+ .package(
+ url: "https://github.com/intitni/CopilotForXcodeKit",
+ branch: "feature/custom-chat-tab"
+ ),
// TreeSitter
.package(url: "https://github.com/intitni/SwiftTreeSitter.git", branch: "main"),
@@ -80,7 +94,7 @@ let package = Package(
targets: [
// MARK: - Helpers
- .target(name: "XPCShared", dependencies: ["SuggestionModel", "Logger"]),
+ .target(name: "XPCShared", dependencies: ["SuggestionBasic", "Logger"]),
.target(name: "Configs"),
@@ -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(
@@ -152,7 +180,7 @@ let package = Package(
),
.target(
- name: "SuggestionModel",
+ name: "SuggestionBasic",
dependencies: [
"LanguageClient",
.product(name: "Parsing", package: "swift-parsing"),
@@ -160,6 +188,15 @@ let package = Package(
]
),
+ .target(
+ name: "SuggestionInjector",
+ dependencies: ["SuggestionBasic"]
+ ),
+ .testTarget(
+ name: "SuggestionInjectorTests",
+ dependencies: ["SuggestionInjector"]
+ ),
+
.target(
name: "AIModel",
dependencies: [
@@ -168,11 +205,51 @@ let package = Package(
),
.testTarget(
- name: "SuggestionModelTests",
- dependencies: ["SuggestionModel"]
+ name: "SuggestionBasicTests",
+ dependencies: ["SuggestionBasic"]
+ ),
+
+ .target(
+ name: "ChatBasic",
+ dependencies: [
+ "AIModel",
+ "Preferences",
+ "Keychain",
+ .product(name: "CodableWrappers", package: "CodableWrappers"),
+ ]
),
- .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",
@@ -186,7 +263,7 @@ let package = Package(
name: "XcodeInspector",
dependencies: [
"AXExtension",
- "SuggestionModel",
+ "SuggestionBasic",
"AXNotificationStream",
"Logger",
"Toast",
@@ -205,8 +282,12 @@ let package = Package(
.target(
name: "BuiltinExtension",
dependencies: [
- "SuggestionModel",
+ "SuggestionBasic",
+ "SuggestionProvider",
+ "ChatBasic",
"Workspace",
+ "ChatTab",
+ "AIModel",
.product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"),
]
),
@@ -216,16 +297,16 @@ let package = Package(
dependencies: [
"Highlightr",
"Preferences",
- "SuggestionModel",
+ "SuggestionBasic",
"DebounceFunction",
- .product(name: "STTextView", package: "STTextView"),
+ "CodeDiff",
.product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
]
),
.testTarget(name: "SharedUIComponentsTests", dependencies: ["SharedUIComponents"]),
.target(name: "ASTParser", dependencies: [
- "SuggestionModel",
+ "SuggestionBasic",
.product(name: "SwiftTreeSitter", package: "SwiftTreeSitter"),
.product(name: "TreeSitterObjC", package: "tree-sitter-objc"),
]),
@@ -237,7 +318,7 @@ let package = Package(
dependencies: [
"GitIgnoreCheck",
"UserDefaultsObserver",
- "SuggestionModel",
+ "SuggestionBasic",
"Logger",
"Preferences",
"XcodeInspector",
@@ -251,6 +332,7 @@ let package = Package(
"SuggestionProvider",
"XPCShared",
"BuiltinExtension",
+ "SuggestionInjector",
]
),
@@ -259,7 +341,7 @@ let package = Package(
dependencies: [
"Preferences",
"ASTParser",
- "SuggestionModel",
+ "SuggestionBasic",
.product(name: "SwiftSyntax", package: "swift-syntax"),
.product(name: "SwiftParser", package: "swift-syntax"),
]
@@ -281,6 +363,17 @@ let package = Package(
]
),
+ .target(
+ name: "CommandHandler",
+ dependencies: [
+ "XcodeInspector",
+ "Preferences",
+ "ChatBasic",
+ "ModificationBasic",
+ .product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
+ ]
+ ),
+
// MARK: - Services
.target(
@@ -289,38 +382,59 @@ let package = Package(
"OpenAIService",
"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: [
- "SuggestionModel",
+ "SuggestionBasic",
"UserDefaultsObserver",
"Preferences",
+ "Logger",
.product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"),
]),
.testTarget(name: "SuggestionProviderTests", dependencies: ["SuggestionProvider"]),
+ .target(
+ name: "RAGChatAgent",
+ dependencies: [
+ "ChatBasic",
+ "ChatContextCollector",
+ "OpenAIService",
+ "Preferences",
+ ]
+ ),
+
// MARK: - GitHub Copilot
.target(
name: "GitHubCopilotService",
dependencies: [
"LanguageClient",
- "SuggestionModel",
+ "SuggestionBasic",
+ "ChatBasic",
"Logger",
"Preferences",
"Terminal",
"BuiltinExtension",
"Toast",
+ "SuggestionProvider",
+ .product(name: "JSONRPC", package: "JSONRPC"),
.product(name: "LanguageServerProtocol", package: "LanguageServerProtocol"),
.product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"),
],
- resources: [.copy("Resources/load-self-signed-cert.js")]
+ resources: [.copy("Resources/load-self-signed-cert-1.34.0.js")]
),
.testTarget(
name: "GitHubCopilotServiceTests",
@@ -334,11 +448,14 @@ let package = Package(
dependencies: [
"LanguageClient",
"Keychain",
- "SuggestionModel",
+ "SuggestionBasic",
"Preferences",
"Terminal",
"XcodeInspector",
"BuiltinExtension",
+ "ChatTab",
+ "SharedUIComponents",
+ .product(name: "JSONRPC", package: "JSONRPC"),
.product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"),
]
),
@@ -352,6 +469,10 @@ let package = Package(
"Preferences",
"TokenEncoder",
"Keychain",
+ "BuiltinExtension",
+ "ChatBasic",
+ "GitHubCopilotService",
+ "JoinJSON",
.product(name: "JSONRPC", package: "JSONRPC"),
.product(name: "AsyncAlgorithms", package: "swift-async-algorithms"),
.product(name: "GoogleGenerativeAI", package: "generative-ai-swift"),
@@ -365,6 +486,7 @@ let package = Package(
name: "OpenAIServiceTests",
dependencies: [
"OpenAIService",
+ "ChatBasic",
.product(
name: "ComposableArchitecture",
package: "swift-composable-architecture"
@@ -376,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
@@ -387,7 +515,8 @@ let package = Package(
.target(
name: "ChatContextCollector",
dependencies: [
- "SuggestionModel",
+ "SuggestionBasic",
+ "ChatBasic",
"OpenAIService",
]
),
@@ -395,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/ASTParser/ASTParser.swift b/Tool/Sources/ASTParser/ASTParser.swift
index dd11d709..1f66fa85 100644
--- a/Tool/Sources/ASTParser/ASTParser.swift
+++ b/Tool/Sources/ASTParser/ASTParser.swift
@@ -1,4 +1,4 @@
-import SuggestionModel
+import SuggestionBasic
import SwiftTreeSitter
import tree_sitter
import TreeSitterObjC
diff --git a/Tool/Sources/AXExtension/AXUIElement.swift b/Tool/Sources/AXExtension/AXUIElement.swift
index d0dc22d0..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
@@ -135,6 +167,28 @@ public extension AXUIElement {
(try? copyValue(key: "AXFullScreen")) ?? false
}
+ var windowID: CGWindowID? {
+ var identifier: CGWindowID = 0
+ let error = AXUIElementGetWindow(self, &identifier)
+ if error == .success {
+ return identifier
+ }
+ return nil
+ }
+
+ var isFrontmost: Bool {
+ get {
+ (try? copyValue(key: kAXFrontmostAttribute)) ?? false
+ }
+ set {
+ AXUIElementSetAttributeValue(
+ self,
+ kAXFrontmostAttribute as CFString,
+ newValue as CFBoolean
+ )
+ }
+ }
+
var focusedWindow: AXUIElement? {
try? copyValue(key: kAXFocusedWindowAttribute)
}
@@ -166,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 }
@@ -181,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
}
@@ -204,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
}
}
@@ -229,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 {
@@ -260,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 39ec18de..80cdaccb 100644
--- a/Tool/Sources/BuiltinExtension/BuiltinExtension.swift
+++ b/Tool/Sources/BuiltinExtension/BuiltinExtension.swift
@@ -1,13 +1,89 @@
+import ChatBasic
+import ChatTab
import CopilotForXcodeKit
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 CustomChatTab] { get }
/// It's usually called when the app is about to quit,
/// you should clean up all the resources here.
func terminate()
}
+// MARK: - Default Implementation
+
+public extension BuiltinExtension {
+ var suggestionServiceId: BuiltInSuggestionFeatureProvider? { nil }
+ var chatTabTypes: [any CustomChatTab] { [] }
+}
+
+// MAKR: - ChatService
+
+/// A temporary protocol for ChatServiceType. Migrate it to CopilotForXcodeKit when finished.
+public protocol BuiltinExtensionChatServiceType: ChatServiceType {
+ typealias Message = ChatMessage
+
+ func sendMessage(
+ _ message: String,
+ history: [Message],
+ references: [RetrievedContent],
+ workspace: WorkspaceInfo
+ ) async -> AsyncThrowingStream
+}
+
+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
+ }
+}
+
+public enum ChatServiceMemoryMutation: Codable {
+ public typealias Message = ChatMessage
+
+ /// Add a new message to the end of memory.
+ /// If an id is not provided, a new id will be generated.
+ /// If an id is provided, and a message with the same id exists the message with the same
+ /// id will be updated.
+ case appendMessage(id: String?, role: Message.Role, text: String)
+ /// Update the message with the given id.
+ case updateMessage(id: String, role: Message.Role, text: String)
+ /// Stream the content into a message with the given id.
+ 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 11dba564..86832df5 100644
--- a/Tool/Sources/BuiltinExtension/BuiltinExtensionManager.swift
+++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionManager.swift
@@ -5,16 +5,21 @@ import XcodeInspector
public final class BuiltinExtensionManager {
public static let shared: BuiltinExtensionManager = .init()
- private(set) var extensions: [any BuiltinExtension] = []
-
- private var cancellable: Set = []
+ public private(set) var extensions: [any BuiltinExtension] = []
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/BuiltinExtension/BuiltinExtensionSuggestionServiceProvider.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionSuggestionServiceProvider.swift
index bc8f5ae1..f6234ddf 100644
--- a/Tool/Sources/BuiltinExtension/BuiltinExtensionSuggestionServiceProvider.swift
+++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionSuggestionServiceProvider.swift
@@ -2,7 +2,7 @@ import CopilotForXcodeKit
import Foundation
import Logger
import Preferences
-import SuggestionModel
+import SuggestionBasic
import SuggestionProvider
public final class BuiltinExtensionSuggestionServiceProvider<
@@ -42,7 +42,7 @@ public final class BuiltinExtensionSuggestionServiceProvider<
public func getSuggestions(
_ request: SuggestionProvider.SuggestionRequest,
workspaceInfo: CopilotForXcodeKit.WorkspaceInfo
- ) async throws -> [SuggestionModel.CodeSuggestion] {
+ ) async throws -> [SuggestionBasic.CodeSuggestion] {
guard let service else {
Logger.service.error("Builtin suggestion service not found.")
throw BuiltinExtensionSuggestionServiceNotFoundError()
@@ -80,7 +80,7 @@ public final class BuiltinExtensionSuggestionServiceProvider<
}
public func notifyAccepted(
- _ suggestion: SuggestionModel.CodeSuggestion,
+ _ suggestion: SuggestionBasic.CodeSuggestion,
workspaceInfo: CopilotForXcodeKit.WorkspaceInfo
) async {
guard let service else {
@@ -91,7 +91,7 @@ public final class BuiltinExtensionSuggestionServiceProvider<
}
public func notifyRejected(
- _ suggestions: [SuggestionModel.CodeSuggestion],
+ _ suggestions: [SuggestionBasic.CodeSuggestion],
workspaceInfo: CopilotForXcodeKit.WorkspaceInfo
) async {
guard let service else {
@@ -123,7 +123,7 @@ extension SuggestionProvider.SuggestionRequest {
}
}
-extension SuggestionModel.CodeSuggestion {
+extension SuggestionBasic.CodeSuggestion {
var converted: CopilotForXcodeKit.CodeSuggestion {
.init(
id: id,
@@ -147,7 +147,7 @@ extension SuggestionModel.CodeSuggestion {
}
extension CopilotForXcodeKit.CodeSuggestion {
- var converted: SuggestionModel.CodeSuggestion {
+ var converted: SuggestionBasic.CodeSuggestion {
.init(
id: id,
text: text,
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/OpenAIService/FucntionCall/ChatGPTFunction.swift b/Tool/Sources/ChatBasic/ChatGPTFunction.swift
similarity index 63%
rename from Tool/Sources/OpenAIService/FucntionCall/ChatGPTFunction.swift
rename to Tool/Sources/ChatBasic/ChatGPTFunction.swift
index d2d8aaad..2a5a4af0 100644
--- a/Tool/Sources/OpenAIService/FucntionCall/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,12 +116,12 @@ public extension ChatGPTArgumentsCollectingFunction {
}
}
-struct ChatGPTFunctionSchema: Codable, Equatable {
- var name: String
- var description: String
- var parameters: JSONSchemaValue
+public struct ChatGPTFunctionSchema: Codable, Equatable, Sendable {
+ public var name: String
+ public var description: String
+ public var parameters: JSONSchemaValue
- init(name: String, description: String, parameters: JSONSchemaValue) {
+ public init(name: String, description: String, parameters: JSONSchemaValue) {
self.name = name
self.description = description
self.parameters = parameters
diff --git a/Tool/Sources/ChatBasic/ChatMessage.swift b/Tool/Sources/ChatBasic/ChatMessage.swift
new file mode 100644
index 00000000..ab5f04a4
--- /dev/null
+++ b/Tool/Sources/ChatBasic/ChatMessage.swift
@@ -0,0 +1,230 @@
+@preconcurrency import CodableWrappers
+import Foundation
+
+/// A chat message that can be sent or received.
+public struct ChatMessage: Equatable, Codable, Sendable {
+ public typealias ID = String
+
+ /// 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.
+ }
+
+ /// 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
+ self.arguments = arguments
+ }
+ }
+
+ /// 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,
+ type: String,
+ function: FunctionCall,
+ response: ToolCallResponse? = nil
+ ) {
+ self.id = id
+ self.type = type
+ self.function = function
+ self.response = response ?? .init(content: "", summary: nil)
+ }
+ }
+
+ /// 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
+ self.summary = summary
+ }
+ }
+
+ /// 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
+ /// 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
+ /// The content of the reference.
+ public var content: String
+ /// The kind of the reference.
+ @FallbackDecoding