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/ServiceDelegate.swift b/CommunicationBridge/ServiceDelegate.swift
new file mode 100644
index 00000000..8a064aef
--- /dev/null
+++ b/CommunicationBridge/ServiceDelegate.swift
@@ -0,0 +1,165 @@
+import AppKit
+import Foundation
+import Logger
+import XPCShared
+
+class ServiceDelegate: NSObject, NSXPCListenerDelegate {
+ func listener(
+ _: NSXPCListener,
+ shouldAcceptNewConnection newConnection: NSXPCConnection
+ ) -> Bool {
+ newConnection.exportedInterface = NSXPCInterface(
+ with: CommunicationBridgeXPCServiceProtocol.self
+ )
+
+ let exportedObject = XPCService()
+ newConnection.exportedObject = exportedObject
+ newConnection.resume()
+
+ Logger.communicationBridge.info("Accepted new connection.")
+
+ return true
+ }
+}
+
+class XPCService: CommunicationBridgeXPCServiceProtocol {
+ static let eventHandler = EventHandler()
+
+ func launchExtensionServiceIfNeeded(
+ withReply reply: @escaping (NSXPCListenerEndpoint?) -> Void
+ ) {
+ Task {
+ await Self.eventHandler.launchExtensionServiceIfNeeded(withReply: reply)
+ }
+ }
+
+ func quit(withReply reply: @escaping () -> Void) {
+ Task {
+ await Self.eventHandler.quit(withReply: reply)
+ }
+ }
+
+ func updateServiceEndpoint(
+ endpoint: NSXPCListenerEndpoint,
+ withReply reply: @escaping () -> Void
+ ) {
+ Task {
+ await Self.eventHandler.updateServiceEndpoint(endpoint: endpoint, withReply: reply)
+ }
+ }
+}
+
+actor EventHandler {
+ var endpoint: NSXPCListenerEndpoint?
+ let launcher = ExtensionServiceLauncher()
+ var exitTask: Task?
+
+ init() {
+ Task { await rescheduleExitTask() }
+ }
+
+ func launchExtensionServiceIfNeeded(
+ withReply reply: @escaping (NSXPCListenerEndpoint?) -> Void
+ ) async {
+ rescheduleExitTask()
+ #if DEBUG
+ if let endpoint, !(await testXPCListenerEndpoint(endpoint)) {
+ self.endpoint = nil
+ }
+ reply(endpoint)
+ #else
+ if await launcher.isApplicationValid {
+ Logger.communicationBridge.info("Service app is still valid")
+ reply(endpoint)
+ } else {
+ endpoint = nil
+ await launcher.launch()
+ reply(nil)
+ }
+ #endif
+ }
+
+ func quit(withReply reply: () -> Void) {
+ Logger.communicationBridge.info("Exiting service.")
+ listener.invalidate()
+ exit(0)
+ }
+
+ func updateServiceEndpoint(endpoint: NSXPCListenerEndpoint, withReply reply: () -> Void) {
+ rescheduleExitTask()
+ self.endpoint = endpoint
+ reply()
+ }
+
+ /// The bridge will kill itself when it's not used for a period.
+ /// It's fine that the bridge is killed because it will be launched again when needed.
+ private func rescheduleExitTask() {
+ exitTask?.cancel()
+ exitTask = Task {
+ #if DEBUG
+ try await Task.sleep(nanoseconds: 60_000_000_000)
+ Logger.communicationBridge.info("Exit will be called in release build.")
+ #else
+ try await Task.sleep(nanoseconds: 1_800_000_000_000)
+ Logger.communicationBridge.info("Exiting service.")
+ listener.invalidate()
+ exit(0)
+ #endif
+ }
+ }
+}
+
+actor ExtensionServiceLauncher {
+ let appIdentifier = bundleIdentifierBase.appending(".ExtensionService")
+ let appURL = Bundle.main.bundleURL.appendingPathComponent(
+ "CopilotForXcodeExtensionService.app"
+ )
+ var isLaunching: Bool = false
+ var application: NSRunningApplication?
+ var isApplicationValid: Bool {
+ guard let application else { return false }
+ if application.isTerminated { return false }
+ let identifier = application.processIdentifier
+ if let application = NSWorkspace.shared.runningApplications.first(where: {
+ $0.processIdentifier == identifier
+ }) {
+ Logger.communicationBridge.info(
+ "Service app found: \(application.processIdentifier) \(String(describing: application.bundleIdentifier))"
+ )
+ return true
+ }
+ return false
+ }
+
+ func launch() {
+ guard !isLaunching else { return }
+ isLaunching = true
+
+ Logger.communicationBridge.info("Launching extension service app.")
+
+ NSWorkspace.shared.openApplication(
+ at: appURL,
+ configuration: {
+ let configuration = NSWorkspace.OpenConfiguration()
+ configuration.createsNewApplicationInstance = false
+ configuration.addsToRecentItems = false
+ configuration.activates = false
+ return configuration
+ }()
+ ) { app, error in
+ if let error = error {
+ Logger.communicationBridge.error(
+ "Failed to launch extension service app: \(error)"
+ )
+ } else {
+ Logger.communicationBridge.info(
+ "Finished launching extension service app."
+ )
+ }
+
+ self.application = app
+ self.isLaunching = false
+ }
+ }
+}
+
diff --git a/CommunicationBridge/main.swift b/CommunicationBridge/main.swift
new file mode 100644
index 00000000..bb449566
--- /dev/null
+++ b/CommunicationBridge/main.swift
@@ -0,0 +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()
+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 ad49af50..056e5761 100644
--- a/Copilot for Xcode.xcodeproj/project.pbxproj
+++ b/Copilot for Xcode.xcodeproj/project.pbxproj
@@ -31,6 +31,17 @@
C861E61E2994F6150056CB02 /* Service in Frameworks */ = {isa = PBXBuildFile; productRef = C861E61D2994F6150056CB02 /* Service */; };
C861E6202994F63A0056CB02 /* ServiceDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C861E61F2994F6390056CB02 /* ServiceDelegate.swift */; };
C86612F82A06AF74009197D9 /* HostApp in Frameworks */ = {isa = PBXBuildFile; productRef = C86612F72A06AF74009197D9 /* HostApp */; };
+ C8738B662BE4D4B900609E7F /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8738B652BE4D4B900609E7F /* main.swift */; };
+ C8738B6B2BE4D56F00609E7F /* ServiceDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8738B6A2BE4D56F00609E7F /* ServiceDelegate.swift */; };
+ C8738B6F2BE4F7A600609E7F /* XPCShared in Frameworks */ = {isa = PBXBuildFile; productRef = C8738B6E2BE4F7A600609E7F /* XPCShared */; };
+ C8738B712BE4F8B700609E7F /* XPCController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8738B702BE4F8B700609E7F /* XPCController.swift */; };
+ C8738B7B2BE5363800609E7F /* SandboxedClientTesterApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8738B7A2BE5363800609E7F /* SandboxedClientTesterApp.swift */; };
+ C8738B7D2BE5363800609E7F /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8738B7C2BE5363800609E7F /* ContentView.swift */; };
+ C8738B7F2BE5363900609E7F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C8738B7E2BE5363900609E7F /* Assets.xcassets */; };
+ C8738B822BE5363900609E7F /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C8738B812BE5363900609E7F /* Preview Assets.xcassets */; };
+ C8738B882BE5365000609E7F /* Client in Frameworks */ = {isa = PBXBuildFile; productRef = C8738B872BE5365000609E7F /* Client */; };
+ C8738B8A2BE540D000609E7F /* bridgeLaunchAgent.plist in Copy Launch Agent */ = {isa = PBXBuildFile; fileRef = C8738B6D2BE4F3E800609E7F /* bridgeLaunchAgent.plist */; };
+ C8738B8B2BE540DD00609E7F /* CommunicationBridge in Embed XPCService */ = {isa = PBXBuildFile; fileRef = C8738B632BE4D4B900609E7F /* CommunicationBridge */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
C8758E7029F04BFF00D29C1C /* CustomCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8758E6F29F04BFF00D29C1C /* CustomCommand.swift */; };
C8758E7229F04CF100D29C1C /* SeparatorCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8758E7129F04CF100D29C1C /* SeparatorCommand.swift */; };
C87B03A5293B261200C77EAE /* AcceptSuggestionCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C87B03A4293B261200C77EAE /* AcceptSuggestionCommand.swift */; };
@@ -42,9 +53,8 @@
C882175C294187EF00A22FD3 /* Client in Frameworks */ = {isa = PBXBuildFile; productRef = C882175B294187EF00A22FD3 /* Client */; };
C89E75C32A46FB32000DD64F /* AppDelegate+Menu.swift in Sources */ = {isa = PBXBuildFile; fileRef = C89E75C22A46FB32000DD64F /* AppDelegate+Menu.swift */; };
C8C8B60929AFA35F00034BEE /* CopilotForXcodeExtensionService.app in Embed XPCService */ = {isa = PBXBuildFile; fileRef = C861E60E2994F6070056CB02 /* CopilotForXcodeExtensionService.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
- C8DCF00029CE11D500FDDDD7 /* ChatWithSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8DCEFFF29CE11D500FDDDD7 /* ChatWithSelection.swift */; };
+ C8DCF00029CE11D500FDDDD7 /* OpenChat.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8DCEFFF29CE11D500FDDDD7 /* OpenChat.swift */; };
C8DD9CB12BC673F80036641C /* CloseIdleTabsCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8DD9CB02BC673F80036641C /* CloseIdleTabsCommand.swift */; };
- C8F1032B2A7A39D700D28F4F /* launchAgent.plist in Copy Launch Agent */ = {isa = PBXBuildFile; fileRef = C8F103292A7A365000D28F4F /* launchAgent.plist */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -69,6 +79,13 @@
remoteGlobalIDString = C8216B6F298036EC00AD38C7;
remoteInfo = Helper;
};
+ C8738B8C2BE540F900609E7F /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = C8189B0E2938972F00C9DCDA /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = C8738B622BE4D4B900609E7F;
+ remoteInfo = CommunicationBridge;
+ };
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
@@ -109,12 +126,22 @@
dstPath = ../Applications;
dstSubfolderSpec = 6;
files = (
+ C8738B8B2BE540DD00609E7F /* CommunicationBridge in Embed XPCService */,
C8216B802980378300AD38C7 /* Helper in Embed XPCService */,
C8C8B60929AFA35F00034BEE /* CopilotForXcodeExtensionService.app in Embed XPCService */,
);
name = "Embed XPCService";
runOnlyForDeploymentPostprocessing = 0;
};
+ C8738B612BE4D4B900609E7F /* CopyFiles */ = {
+ isa = PBXCopyFilesBuildPhase;
+ buildActionMask = 12;
+ dstPath = "";
+ dstSubfolderSpec = 16;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
C87B03AE293B2CF300C77EAE /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
@@ -142,7 +169,7 @@
dstPath = Contents/Library/LaunchAgents;
dstSubfolderSpec = 1;
files = (
- C8F1032B2A7A39D700D28F4F /* launchAgent.plist in Copy Launch Agent */,
+ C8738B8A2BE540D000609E7F /* bridgeLaunchAgent.plist in Copy Launch Agent */,
);
name = "Copy Launch Agent";
runOnlyForDeploymentPostprocessing = 0;
@@ -178,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 = ""; };
@@ -187,6 +214,18 @@
C861E6142994F6080056CB02 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
C861E6192994F6080056CB02 /* ExtensionService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ExtensionService.entitlements; sourceTree = ""; };
C861E61F2994F6390056CB02 /* ServiceDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceDelegate.swift; sourceTree = ""; };
+ C8738B632BE4D4B900609E7F /* CommunicationBridge */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = CommunicationBridge; sourceTree = BUILT_PRODUCTS_DIR; };
+ C8738B652BE4D4B900609E7F /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; };
+ C8738B6A2BE4D56F00609E7F /* ServiceDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceDelegate.swift; sourceTree = ""; };
+ C8738B6D2BE4F3E800609E7F /* bridgeLaunchAgent.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = bridgeLaunchAgent.plist; sourceTree = ""; };
+ C8738B702BE4F8B700609E7F /* XPCController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XPCController.swift; sourceTree = ""; };
+ C8738B782BE5363800609E7F /* SandboxedClientTester.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SandboxedClientTester.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ C8738B7A2BE5363800609E7F /* SandboxedClientTesterApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SandboxedClientTesterApp.swift; sourceTree = ""; };
+ C8738B7C2BE5363800609E7F /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; };
+ C8738B7E2BE5363900609E7F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
+ C8738B812BE5363900609E7F /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; };
+ C8738B832BE5363900609E7F /* SandboxedClientTester.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = SandboxedClientTester.entitlements; sourceTree = ""; };
+ C8738B892BE5379E00609E7F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; };
C8758E6F29F04BFF00D29C1C /* CustomCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomCommand.swift; sourceTree = ""; };
C8758E7129F04CF100D29C1C /* SeparatorCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeparatorCommand.swift; sourceTree = ""; };
C87B03A3293B24AB00C77EAE /* Copilot-for-Xcode-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "Copilot-for-Xcode-Info.plist"; sourceTree = SOURCE_ROOT; };
@@ -196,8 +235,9 @@
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 /* ChatWithSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatWithSelection.swift; 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 = ""; };
C8F103292A7A365000D28F4F /* launchAgent.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = launchAgent.plist; sourceTree = ""; };
/* End PBXFileReference section */
@@ -237,6 +277,22 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ C8738B602BE4D4B900609E7F /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ C8738B6F2BE4F7A600609E7F /* XPCShared in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ C8738B752BE5363800609E7F /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ C8738B882BE5365000609E7F /* Client in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@@ -265,7 +321,7 @@
C8009C022941C576007AA7E8 /* RealtimeSuggestionCommand.swift */,
C800DBB0294C624D00B04CAC /* PrefetchSuggestionsCommand.swift */,
C8758E6F29F04BFF00D29C1C /* CustomCommand.swift */,
- C8DCEFFF29CE11D500FDDDD7 /* ChatWithSelection.swift */,
+ C8DCEFFF29CE11D500FDDDD7 /* OpenChat.swift */,
C8DD9CB02BC673F80036641C /* CloseIdleTabsCommand.swift */,
C861A6A229E5503F005C41A3 /* PromptToCodeCommand.swift */,
C81458972939EFDC00135263 /* Info.plist */,
@@ -280,19 +336,23 @@
C887BC832965D96000931567 /* DEVELOPMENT.md */,
C8520308293D805800460097 /* README.md */,
C82E38492A1F025F00D4EADF /* LICENSE */,
- C83E5DED2A38CD8C0071506D /* Makefile */,
C8F103292A7A365000D28F4F /* launchAgent.plist */,
+ C8738B6D2BE4F3E800609E7F /* bridgeLaunchAgent.plist */,
C81E867D296FE4420026E908 /* Version.xcconfig */,
C81458AD293A009600135263 /* Config.xcconfig */,
C81458AE293A009800135263 /* Config.debug.xcconfig */,
C8CD828229B88006008D044D /* TestPlan.xctestplan */,
C828B27D2B1F241500E7612A /* ExtensionPoint.appextensionpoint */,
+ C8BE64922EB9B42E00EDB2D7 /* OverlayWindow */,
+ C84FD9D72CC671C600BE5093 /* ChatPlugins */,
C81D181E2A1B509B006C1B70 /* Tool */,
C8189B282938979000C9DCDA /* Core */,
C8189B182938972F00C9DCDA /* Copilot for Xcode */,
C81458922939EFDC00135263 /* EditorExtension */,
C8216B71298036EC00AD38C7 /* Helper */,
C861E60F2994F6070056CB02 /* ExtensionService */,
+ C8738B642BE4D4B900609E7F /* CommunicationBridge */,
+ C8738B792BE5363800609E7F /* SandboxedClientTester */,
C814588D2939EFDC00135263 /* Frameworks */,
C8189B172938972F00C9DCDA /* Products */,
);
@@ -305,6 +365,8 @@
C814588C2939EFDC00135263 /* Copilot.appex */,
C8216B70298036EC00AD38C7 /* Helper */,
C861E60E2994F6070056CB02 /* CopilotForXcodeExtensionService.app */,
+ C8738B632BE4D4B900609E7F /* CommunicationBridge */,
+ C8738B782BE5363800609E7F /* SandboxedClientTester.app */,
);
name = Products;
sourceTree = "";
@@ -345,6 +407,7 @@
C861E61F2994F6390056CB02 /* ServiceDelegate.swift */,
C861E6102994F6070056CB02 /* AppDelegate.swift */,
C89E75C22A46FB32000DD64F /* AppDelegate+Menu.swift */,
+ C8738B702BE4F8B700609E7F /* XPCController.swift */,
C81291D52994FE6900196E12 /* Main.storyboard */,
C861E6142994F6080056CB02 /* Assets.xcassets */,
C861E6192994F6080056CB02 /* ExtensionService.entitlements */,
@@ -352,6 +415,36 @@
path = ExtensionService;
sourceTree = "";
};
+ C8738B642BE4D4B900609E7F /* CommunicationBridge */ = {
+ isa = PBXGroup;
+ children = (
+ C8738B652BE4D4B900609E7F /* main.swift */,
+ C8738B6A2BE4D56F00609E7F /* ServiceDelegate.swift */,
+ );
+ path = CommunicationBridge;
+ sourceTree = "";
+ };
+ C8738B792BE5363800609E7F /* SandboxedClientTester */ = {
+ isa = PBXGroup;
+ children = (
+ C8738B892BE5379E00609E7F /* Info.plist */,
+ C8738B7A2BE5363800609E7F /* SandboxedClientTesterApp.swift */,
+ C8738B7C2BE5363800609E7F /* ContentView.swift */,
+ C8738B7E2BE5363900609E7F /* Assets.xcassets */,
+ C8738B832BE5363900609E7F /* SandboxedClientTester.entitlements */,
+ C8738B802BE5363900609E7F /* Preview Content */,
+ );
+ path = SandboxedClientTester;
+ sourceTree = "";
+ };
+ C8738B802BE5363900609E7F /* Preview Content */ = {
+ isa = PBXGroup;
+ children = (
+ C8738B812BE5363900609E7F /* Preview Assets.xcassets */,
+ );
+ path = "Preview Content";
+ sourceTree = "";
+ };
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -391,6 +484,7 @@
buildRules = (
);
dependencies = (
+ C8738B8D2BE540F900609E7F /* PBXTargetDependency */,
C81291B02994F92700196E12 /* PBXTargetDependency */,
C8216B7F2980377E00AD38C7 /* PBXTargetDependency */,
C814589A2939EFDC00135263 /* PBXTargetDependency */,
@@ -444,6 +538,46 @@
productReference = C861E60E2994F6070056CB02 /* CopilotForXcodeExtensionService.app */;
productType = "com.apple.product-type.application";
};
+ C8738B622BE4D4B900609E7F /* CommunicationBridge */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = C8738B672BE4D4B900609E7F /* Build configuration list for PBXNativeTarget "CommunicationBridge" */;
+ buildPhases = (
+ C8738B5F2BE4D4B900609E7F /* Sources */,
+ C8738B602BE4D4B900609E7F /* Frameworks */,
+ C8738B612BE4D4B900609E7F /* CopyFiles */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = CommunicationBridge;
+ packageProductDependencies = (
+ C8738B6E2BE4F7A600609E7F /* XPCShared */,
+ );
+ productName = CommunicationBridge;
+ productReference = C8738B632BE4D4B900609E7F /* CommunicationBridge */;
+ productType = "com.apple.product-type.tool";
+ };
+ C8738B772BE5363800609E7F /* SandboxedClientTester */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = C8738B842BE5363900609E7F /* Build configuration list for PBXNativeTarget "SandboxedClientTester" */;
+ buildPhases = (
+ C8738B742BE5363800609E7F /* Sources */,
+ C8738B752BE5363800609E7F /* Frameworks */,
+ C8738B762BE5363800609E7F /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = SandboxedClientTester;
+ packageProductDependencies = (
+ C8738B872BE5365000609E7F /* Client */,
+ );
+ productName = SandboxedClientTester;
+ productReference = C8738B782BE5363800609E7F /* SandboxedClientTester.app */;
+ productType = "com.apple.product-type.application";
+ };
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -466,6 +600,12 @@
C861E60D2994F6070056CB02 = {
CreatedOnToolsVersion = 14.2;
};
+ C8738B622BE4D4B900609E7F = {
+ CreatedOnToolsVersion = 15.2;
+ };
+ C8738B772BE5363800609E7F = {
+ CreatedOnToolsVersion = 15.2;
+ };
};
};
buildConfigurationList = C8189B112938972F00C9DCDA /* Build configuration list for PBXProject "Copilot for Xcode" */;
@@ -489,6 +629,8 @@
C814588B2939EFDC00135263 /* EditorExtension */,
C8216B6F298036EC00AD38C7 /* Helper */,
C861E60D2994F6070056CB02 /* ExtensionService */,
+ C8738B622BE4D4B900609E7F /* CommunicationBridge */,
+ C8738B772BE5363800609E7F /* SandboxedClientTester */,
);
};
/* End PBXProject section */
@@ -519,6 +661,15 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ C8738B762BE5363800609E7F /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ C8738B822BE5363900609E7F /* Preview Assets.xcassets in Resources */,
+ C8738B7F2BE5363900609E7F /* Assets.xcassets in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -526,7 +677,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
- C8DCF00029CE11D500FDDDD7 /* ChatWithSelection.swift in Sources */,
+ C8DCF00029CE11D500FDDDD7 /* OpenChat.swift in Sources */,
C81458942939EFDC00135263 /* SourceEditorExtension.swift in Sources */,
C8DD9CB12BC673F80036641C /* CloseIdleTabsCommand.swift in Sources */,
C8758E7029F04BFF00D29C1C /* CustomCommand.swift in Sources */,
@@ -567,11 +718,30 @@
buildActionMask = 2147483647;
files = (
C89E75C32A46FB32000DD64F /* AppDelegate+Menu.swift in Sources */,
+ C8738B712BE4F8B700609E7F /* XPCController.swift in Sources */,
C861E6202994F63A0056CB02 /* ServiceDelegate.swift in Sources */,
C861E6112994F6070056CB02 /* AppDelegate.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
+ C8738B5F2BE4D4B900609E7F /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ C8738B6B2BE4D56F00609E7F /* ServiceDelegate.swift in Sources */,
+ C8738B662BE4D4B900609E7F /* main.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ C8738B742BE5363800609E7F /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ C8738B7D2BE5363800609E7F /* ContentView.swift in Sources */,
+ C8738B7B2BE5363800609E7F /* SandboxedClientTesterApp.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
@@ -590,6 +760,11 @@
target = C8216B6F298036EC00AD38C7 /* Helper */;
targetProxy = C8216B7E2980377E00AD38C7 /* PBXContainerItemProxy */;
};
+ C8738B8D2BE540F900609E7F /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = C8738B622BE4D4B900609E7F /* CommunicationBridge */;
+ targetProxy = C8738B8C2BE540F900609E7F /* PBXContainerItemProxy */;
+ };
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
@@ -600,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;
@@ -627,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;
@@ -681,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;
@@ -742,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;
@@ -772,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;
@@ -804,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)";
@@ -829,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;
@@ -842,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;
@@ -860,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;
@@ -873,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)";
@@ -892,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;
@@ -905,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)";
@@ -915,6 +1104,110 @@
};
name = Release;
};
+ C8738B682BE4D4B900609E7F /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CODE_SIGN_STYLE = Automatic;
+ DEVELOPMENT_TEAM = 5YKZ4Y3DAW;
+ ENABLE_HARDENED_RUNTIME = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MACOSX_DEPLOYMENT_TARGET = 13.0;
+ PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).CommunicationBridge";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SKIP_INSTALL = YES;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
+ SWIFT_VERSION = 5.0;
+ };
+ name = Debug;
+ };
+ C8738B692BE4D4B900609E7F /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CODE_SIGN_STYLE = Automatic;
+ DEVELOPMENT_TEAM = 5YKZ4Y3DAW;
+ ENABLE_HARDENED_RUNTIME = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MACOSX_DEPLOYMENT_TARGET = 13.0;
+ PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).CommunicationBridge";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SKIP_INSTALL = YES;
+ SWIFT_VERSION = 5.0;
+ };
+ name = Release;
+ };
+ C8738B852BE5363900609E7F /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_ENTITLEMENTS = SandboxedClientTester/SandboxedClientTester.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ COMBINE_HIDPI_IMAGES = YES;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_ASSET_PATHS = "\"SandboxedClientTester/Preview Content\"";
+ DEVELOPMENT_TEAM = 5YKZ4Y3DAW;
+ ENABLE_HARDENED_RUNTIME = YES;
+ ENABLE_PREVIEWS = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = SandboxedClientTester/Info.plist;
+ INFOPLIST_KEY_NSHumanReadableCopyright = "";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ );
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MACOSX_DEPLOYMENT_TARGET = 14.2;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.intii.CopilotForXcode.SandboxedClientTester;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_VERSION = 5.0;
+ };
+ name = Debug;
+ };
+ C8738B862BE5363900609E7F /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_ENTITLEMENTS = SandboxedClientTester/SandboxedClientTester.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ COMBINE_HIDPI_IMAGES = YES;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_ASSET_PATHS = "\"SandboxedClientTester/Preview Content\"";
+ DEVELOPMENT_TEAM = 5YKZ4Y3DAW;
+ ENABLE_HARDENED_RUNTIME = YES;
+ ENABLE_PREVIEWS = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = SandboxedClientTester/Info.plist;
+ INFOPLIST_KEY_NSHumanReadableCopyright = "";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ );
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MACOSX_DEPLOYMENT_TARGET = 14.2;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.intii.CopilotForXcode.SandboxedClientTester;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_VERSION = 5.0;
+ };
+ name = Release;
+ };
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@@ -963,6 +1256,24 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
+ C8738B672BE4D4B900609E7F /* Build configuration list for PBXNativeTarget "CommunicationBridge" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ C8738B682BE4D4B900609E7F /* Debug */,
+ C8738B692BE4D4B900609E7F /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ C8738B842BE5363900609E7F /* Build configuration list for PBXNativeTarget "SandboxedClientTester" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ C8738B852BE5363900609E7F /* Debug */,
+ C8738B862BE5363900609E7F /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
@@ -998,6 +1309,14 @@
isa = XCSwiftPackageProductDependency;
productName = HostApp;
};
+ C8738B6E2BE4F7A600609E7F /* XPCShared */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = XPCShared;
+ };
+ C8738B872BE5365000609E7F /* Client */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = Client;
+ };
C882175B294187EF00A22FD3 /* Client */ = {
isa = XCSwiftPackageProductDependency;
productName = Client;
diff --git a/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/CommunicationBridge.xcscheme b/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/CommunicationBridge.xcscheme
new file mode 100644
index 00000000..578b11ea
--- /dev/null
+++ b/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/CommunicationBridge.xcscheme
@@ -0,0 +1,79 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 1f9b8f1f..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 @@
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved
index c5870abd..87fd4d4e 100644
--- a/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved
+++ b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -1,5 +1,23 @@
{
"pins" : [
+ {
+ "identity" : "aexml",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/tadija/AEXML.git",
+ "state" : {
+ "revision" : "db806756c989760b35108146381535aec231092b",
+ "version" : "4.7.0"
+ }
+ },
+ {
+ "identity" : "cgeventoverride",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/intitni/CGEventOverride",
+ "state" : {
+ "revision" : "571d36d63e68fac30e4a350600cd186697936f74",
+ "version" : "1.2.3"
+ }
+ },
{
"identity" : "codablewrappers",
"kind" : "remoteSourceControl",
@@ -14,8 +32,17 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/combine-schedulers",
"state" : {
- "revision" : "0625932976b3ae23949f6b816d13bd97f3b40b7c",
- "version" : "0.10.0"
+ "revision" : "5928286acce13def418ec36d05a001a9641086f2",
+ "version" : "1.0.3"
+ }
+ },
+ {
+ "identity" : "copilotforxcodekit",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/intitni/CopilotForXcodeKit",
+ "state" : {
+ "branch" : "feature/custom-chat-tab",
+ "revision" : "63915ee1f8aba5375bc0f0166c8645fe81fe5b88"
}
},
{
@@ -27,6 +54,15 @@
"version" : "1.0.2"
}
},
+ {
+ "identity" : "generative-ai-swift",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/intitni/generative-ai-swift",
+ "state" : {
+ "branch" : "support-setting-base-url",
+ "revision" : "12d7b30b566a64cc0dd628130bfb99a07368fea7"
+ }
+ },
{
"identity" : "glob",
"kind" : "remoteSourceControl",
@@ -41,8 +77,17 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/intitni/Highlightr",
"state" : {
- "branch" : "bump-highlight-js-version",
- "revision" : "4ffbb1b0b721378263297cafea6f2838044eb1eb"
+ "branch" : "master",
+ "revision" : "81d8c8b3733939bf5d9e52cd6318f944cc033bd2"
+ }
+ },
+ {
+ "identity" : "indexstore-db",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/indexstore-db.git",
+ "state" : {
+ "branch" : "release/6.1",
+ "revision" : "54212fce1aecb199070808bdb265e7f17e396015"
}
},
{
@@ -54,6 +99,15 @@
"version" : "0.6.0"
}
},
+ {
+ "identity" : "keyboardshortcuts",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/intitni/KeyboardShortcuts",
+ "state" : {
+ "branch" : "main",
+ "revision" : "65fb410b0c6d3ed96623b460bab31ffce5f48b4d"
+ }
+ },
{
"identity" : "languageclient",
"kind" : "remoteSourceControl",
@@ -72,6 +126,24 @@
"version" : "0.8.0"
}
},
+ {
+ "identity" : "messagepacker",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/hirotakan/MessagePacker.git",
+ "state" : {
+ "revision" : "4d8346c6bc579347e4df0429493760691c5aeca2",
+ "version" : "0.4.7"
+ }
+ },
+ {
+ "identity" : "networkimage",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/gonzalezreal/NetworkImage",
+ "state" : {
+ "revision" : "2849f5323265386e200484b0d0f896e73c3411b9",
+ "version" : "6.0.1"
+ }
+ },
{
"identity" : "operationplus",
"kind" : "remoteSourceControl",
@@ -81,6 +153,15 @@
"version" : "1.6.0"
}
},
+ {
+ "identity" : "pathkit",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/kylef/PathKit.git",
+ "state" : {
+ "revision" : "3bfd2737b700b9a36565a8c94f4ad2b050a5e574",
+ "version" : "1.0.1"
+ }
+ },
{
"identity" : "processenv",
"kind" : "remoteSourceControl",
@@ -90,13 +171,31 @@
"version" : "0.3.1"
}
},
+ {
+ "identity" : "sourcekitten",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/jpsim/SourceKitten",
+ "state" : {
+ "revision" : "eb6656ed26bdef967ad8d07c27e2eab34dc582f2",
+ "version" : "0.37.0"
+ }
+ },
{
"identity" : "sparkle",
"kind" : "remoteSourceControl",
"location" : "https://github.com/sparkle-project/Sparkle",
"state" : {
- "revision" : "87e4fcbac39912f9cdb9a9acf205cad60e1ca3bc",
- "version" : "2.4.2"
+ "revision" : "0ca3004e98712ea2b39dd881d28448630cce1c99",
+ "version" : "2.7.0"
+ }
+ },
+ {
+ "identity" : "spectre",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/kylef/Spectre.git",
+ "state" : {
+ "revision" : "26cc5e9ae0947092c7139ef7ba612e34646086c7",
+ "version" : "0.10.1"
}
},
{
@@ -113,8 +212,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-async-algorithms",
"state" : {
- "revision" : "9cfed92b026c524674ed869a4ff2dcfdeedf8a2a",
- "version" : "0.1.0"
+ "revision" : "042e1c4d9d19748c9c228f8d4ebc97bb1e339b0b",
+ "version" : "1.0.4"
}
},
{
@@ -122,8 +221,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-case-paths",
"state" : {
- "revision" : "fc45e7b2cfece9dd80b5a45e6469ffe67fe67984",
- "version" : "0.14.1"
+ "revision" : "41b89b8b68d8c56c622dbb7132258f1a3e638b25",
+ "version" : "1.7.0"
}
},
{
@@ -131,8 +230,17 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-clocks",
"state" : {
- "revision" : "f9acfa1a45f4483fe0f2c434a74e6f68f865d12d",
- "version" : "0.3.0"
+ "revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e",
+ "version" : "1.0.6"
+ }
+ },
+ {
+ "identity" : "swift-cmark",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/swiftlang/swift-cmark",
+ "state" : {
+ "revision" : "b022b08312decdc46585e0b3440d97f6f22ef703",
+ "version" : "0.6.0"
}
},
{
@@ -140,8 +248,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections",
"state" : {
- "revision" : "937e904258d22af6e447a0b72c0bc67583ef64a2",
- "version" : "1.0.4"
+ "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7",
+ "version" : "1.1.4"
}
},
{
@@ -149,8 +257,17 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-composable-architecture",
"state" : {
- "revision" : "89f80fe2400d21a853abc9556a060a2fa50eb2cb",
- "version" : "0.55.0"
+ "revision" : "69247baf7be2fd6f5820192caef0082d01849cd0",
+ "version" : "1.16.1"
+ }
+ },
+ {
+ "identity" : "swift-concurrency-extras",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-concurrency-extras",
+ "state" : {
+ "revision" : "82a4ae7170d98d8538ec77238b7eb8e7199ef2e8",
+ "version" : "1.3.1"
}
},
{
@@ -158,8 +275,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-custom-dump",
"state" : {
- "revision" : "3a35f7892e7cf6ba28a78cd46a703c0be4e0c6dc",
- "version" : "0.11.0"
+ "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1",
+ "version" : "1.3.3"
}
},
{
@@ -167,8 +284,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-dependencies",
"state" : {
- "revision" : "de1a984a71e51f6e488e98ce3652035563eb8acb",
- "version" : "0.5.1"
+ "revision" : "4c90d6b2b9bf0911af87b103bb40f41771891596",
+ "version" : "1.9.2"
}
},
{
@@ -176,8 +293,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-identified-collections",
"state" : {
- "revision" : "d01446a78fb768adc9a78cbb6df07767c8ccfc29",
- "version" : "0.8.0"
+ "revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597",
+ "version" : "1.1.1"
}
},
{
@@ -185,8 +302,17 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/gonzalezreal/swift-markdown-ui",
"state" : {
- "revision" : "12b351a75201a8124c2f2e1f9fc6ef5cd812c0b9",
- "version" : "2.1.0"
+ "revision" : "5f613358148239d0292c0cef674a3c2314737f9e",
+ "version" : "2.4.1"
+ }
+ },
+ {
+ "identity" : "swift-navigation",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-navigation",
+ "state" : {
+ "revision" : "db6bc9dbfed001f21e6728fd36413d9342c235b4",
+ "version" : "2.3.0"
}
},
{
@@ -194,8 +320,17 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-parsing",
"state" : {
- "revision" : "27c941bbd22a4bbc53005a15a0440443fd892f70",
- "version" : "0.12.1"
+ "revision" : "3432cb81164dd3d69a75d0d63205be5fbae2c34b",
+ "version" : "0.14.1"
+ }
+ },
+ {
+ "identity" : "swift-perception",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-perception",
+ "state" : {
+ "revision" : "d924c62a70fca5f43872f286dbd7cef0957f1c01",
+ "version" : "1.6.0"
}
},
{
@@ -203,8 +338,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-syntax.git",
"state" : {
- "branch" : "main",
- "revision" : "aa3b1e187c9cc568f9d1abc47feb11f6b044d284"
+ "revision" : "0687f71944021d616d34d922343dcef086855920",
+ "version" : "600.0.1"
}
},
{
@@ -216,22 +351,40 @@
"version" : "2.6.1"
}
},
+ {
+ "identity" : "swiftterm",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/migueldeicaza/SwiftTerm",
+ "state" : {
+ "revision" : "e2b431dbf73f775fb4807a33e4572ffd3dc6933a",
+ "version" : "1.2.5"
+ }
+ },
{
"identity" : "swifttreesitter",
"kind" : "remoteSourceControl",
- "location" : "https://github.com/ChimeHQ/SwiftTreeSitter",
+ "location" : "https://github.com/intitni/SwiftTreeSitter.git",
"state" : {
- "revision" : "df25a52f72ebc5b50ae20d26d1363793408bb28b",
- "version" : "0.7.1"
+ "branch" : "main",
+ "revision" : "fd499bfafcccfae12a1a579dc922d8418025a35d"
}
},
{
- "identity" : "swiftui-navigation",
+ "identity" : "swiftui-introspect",
"kind" : "remoteSourceControl",
- "location" : "https://github.com/pointfreeco/swiftui-navigation",
+ "location" : "https://github.com/siteline/swiftui-introspect",
"state" : {
- "revision" : "2aa885e719087ee19df251c08a5980ad3e787f12",
- "version" : "0.8.0"
+ "revision" : "807f73ce09a9b9723f12385e592b4e0aaebd3336",
+ "version" : "1.3.0"
+ }
+ },
+ {
+ "identity" : "swxmlhash",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/drmohundro/SWXMLHash.git",
+ "state" : {
+ "revision" : "a853604c9e9a83ad9954c7e3d2a565273982471f",
+ "version" : "7.0.2"
}
},
{
@@ -253,21 +406,21 @@
}
},
{
- "identity" : "tree-sitter-swift",
+ "identity" : "usearch",
"kind" : "remoteSourceControl",
- "location" : "https://github.com/alex-pinkus/tree-sitter-swift",
+ "location" : "https://github.com/unum-cloud/usearch",
"state" : {
- "branch" : "with-generated-files",
- "revision" : "eda05af7ac41adb4eb19c346883c0fa32fe3bdd8"
+ "revision" : "f2ab884d50902c3ad63f07a3a20bc34008a17449",
+ "version" : "0.19.3"
}
},
{
- "identity" : "usearch",
+ "identity" : "xcodeproj",
"kind" : "remoteSourceControl",
- "location" : "https://github.com/unum-cloud/usearch",
+ "location" : "https://github.com/tuist/XcodeProj.git",
"state" : {
- "revision" : "f2ab884d50902c3ad63f07a3a20bc34008a17449",
- "version" : "0.19.3"
+ "revision" : "b1caa062d4aaab3e3d2bed5fe0ac5f8ce9bf84f4",
+ "version" : "8.27.7"
}
},
{
@@ -275,8 +428,17 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/xctest-dynamic-overlay",
"state" : {
- "revision" : "50843cbb8551db836adec2290bb4bc6bac5c1865",
- "version" : "0.9.0"
+ "revision" : "39de59b2d47f7ef3ca88a039dff3084688fe27f4",
+ "version" : "1.5.2"
+ }
+ },
+ {
+ "identity" : "yams",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/jpsim/Yams.git",
+ "state" : {
+ "revision" : "b4b8042411dc7bbb696300a34a4bf3ba1b7ad19b",
+ "version" : "5.3.1"
}
}
],
diff --git a/Copilot for Xcode/App.swift b/Copilot for Xcode/App.swift
index 094e32d5..99a0a044 100644
--- a/Copilot for Xcode/App.swift
+++ b/Copilot for Xcode/App.swift
@@ -6,10 +6,22 @@ import UpdateChecker
import XPCShared
struct VisualEffect: NSViewRepresentable {
- func makeNSView(context: Self.Context) -> NSView { return NSVisualEffectView() }
- func updateNSView(_ nsView: NSView, context: Context) { }
+ func makeNSView(context: Self.Context) -> NSView { return NSVisualEffectView() }
+ func updateNSView(_ nsView: NSView, context: Context) {}
}
+class TheUpdateCheckerDelegate: UpdateCheckerDelegate {
+ func prepareForRelaunch(finish: @escaping () -> Void) {
+ Task {
+ let service = try? getService()
+ try? await service?.quitService()
+ finish()
+ }
+ }
+}
+
+let updateCheckerDelegate = TheUpdateCheckerDelegate()
+
@main
struct CopilotForXcodeApp: App {
var body: some Scene {
@@ -20,7 +32,17 @@ struct CopilotForXcodeApp: App {
.onAppear {
UserDefaults.setupDefaultSettings()
}
- .environment(\.updateChecker, UpdateChecker(hostBundle: Bundle.main))
+ .environment(
+ \.updateChecker,
+ {
+ let checker = UpdateChecker(
+ hostBundle: Bundle.main,
+ shouldAutomaticallyCheckForUpdate: false
+ )
+ checker.updateCheckerDelegate = updateCheckerDelegate
+ return checker
+ }()
+ )
}
}
}
diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon.png
deleted file mode 100644
index 291eaac7..00000000
Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon.png and /dev/null differ
diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@128w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@128w.png
deleted file mode 100644
index 160db273..00000000
Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@128w.png and /dev/null differ
diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@16w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@16w.png
deleted file mode 100644
index 4fcd6278..00000000
Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@16w.png and /dev/null differ
diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w 1.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w 1.png
deleted file mode 100644
index e31a8d3b..00000000
Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w 1.png and /dev/null differ
diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w.png
deleted file mode 100644
index e31a8d3b..00000000
Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w.png and /dev/null differ
diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w 1.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w 1.png
deleted file mode 100644
index ec264755..00000000
Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w 1.png and /dev/null differ
diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w.png
deleted file mode 100644
index ec264755..00000000
Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w.png and /dev/null differ
diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w 1.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w 1.png
deleted file mode 100644
index 4b760bc1..00000000
Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w 1.png and /dev/null differ
diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w.png
deleted file mode 100644
index 4b760bc1..00000000
Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w.png and /dev/null differ
diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@64w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@64w.png
deleted file mode 100644
index 8d777985..00000000
Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@64w.png and /dev/null differ
diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/Contents.json b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/Contents.json
index 56acb569..457c1fbf 100644
--- a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/Contents.json
+++ b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -1,61 +1,61 @@
{
"images" : [
{
- "filename" : "1024 x 1024 your icon@16w.png",
+ "filename" : "app-icon@16w.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
- "filename" : "1024 x 1024 your icon@32w 1.png",
+ "filename" : "app-icon@32w.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
- "filename" : "1024 x 1024 your icon@32w.png",
+ "filename" : "app-icon@32w.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
- "filename" : "1024 x 1024 your icon@64w.png",
+ "filename" : "app-icon@64w.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
- "filename" : "1024 x 1024 your icon@128w.png",
+ "filename" : "app-icon@128w.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
- "filename" : "1024 x 1024 your icon@256w 1.png",
+ "filename" : "app-icon@256w.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
- "filename" : "1024 x 1024 your icon@256w.png",
+ "filename" : "app-icon@256w.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
- "filename" : "1024 x 1024 your icon@512w 1.png",
+ "filename" : "app-icon@512w.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
- "filename" : "1024 x 1024 your icon@512w.png",
+ "filename" : "app-icon@512w.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
- "filename" : "1024 x 1024 your icon.png",
+ "filename" : "app-icon.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon.png
new file mode 100644
index 00000000..f7d77720
Binary files /dev/null and b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon.png differ
diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@128w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@128w.png
new file mode 100644
index 00000000..da0bb247
Binary files /dev/null and b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@128w.png differ
diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@16w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@16w.png
new file mode 100644
index 00000000..4f3fcc40
Binary files /dev/null and b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@16w.png differ
diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@256w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@256w.png
new file mode 100644
index 00000000..1f70976c
Binary files /dev/null and b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@256w.png differ
diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@32w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@32w.png
new file mode 100644
index 00000000..44400214
Binary files /dev/null and b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@32w.png differ
diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@512w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@512w.png
new file mode 100644
index 00000000..78d81e50
Binary files /dev/null and b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@512w.png differ
diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@64w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@64w.png
new file mode 100644
index 00000000..a6aae457
Binary files /dev/null and b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@64w.png differ
diff --git a/Copilot for Xcode/Copilot_for_Xcode.entitlements b/Copilot for Xcode/Copilot_for_Xcode.entitlements
index 8abc1c41..abefc876 100644
--- a/Copilot for Xcode/Copilot_for_Xcode.entitlements
+++ b/Copilot for Xcode/Copilot_for_Xcode.entitlements
@@ -8,6 +8,8 @@
$(TeamIdentifierPrefix)group.$(BUNDLE_IDENTIFIER_BASE)
+ com.apple.security.automation.apple-events
+
com.apple.security.files.user-selected.read-only
keychain-access-groups
diff --git a/Copilot-for-Xcode-Info.plist b/Copilot-for-Xcode-Info.plist
index 07a19a85..9f9fdd6e 100644
--- a/Copilot-for-Xcode-Info.plist
+++ b/Copilot-for-Xcode-Info.plist
@@ -12,6 +12,11 @@
$(EXTENSION_BUNDLE_NAME)
HOST_APP_NAME
$(HOST_APP_NAME)
+ NSAppTransportSecurity
+
+ NSAllowsArbitraryLoads
+
+
SUEnableJavaScript
YES
SUFeedURL
diff --git a/Core/.swiftpm/xcode/xcshareddata/xcschemes/Core-Package.xcscheme b/Core/.swiftpm/xcode/xcshareddata/xcschemes/Client.xcscheme
similarity index 84%
rename from Core/.swiftpm/xcode/xcshareddata/xcschemes/Core-Package.xcscheme
rename to Core/.swiftpm/xcode/xcshareddata/xcschemes/Client.xcscheme
index b5513aeb..0deca224 100644
--- a/Core/.swiftpm/xcode/xcshareddata/xcschemes/Core-Package.xcscheme
+++ b/Core/.swiftpm/xcode/xcshareddata/xcschemes/Client.xcscheme
@@ -1,10 +1,11 @@
+ buildImplicitDependencies = "YES"
+ buildArchitectures = "Automatic">
@@ -49,9 +50,9 @@
diff --git a/Tool/.swiftpm/xcode/xcshareddata/xcschemes/Tool-Package.xcscheme b/Core/.swiftpm/xcode/xcshareddata/xcschemes/HostApp.xcscheme
similarity index 84%
rename from Tool/.swiftpm/xcode/xcshareddata/xcschemes/Tool-Package.xcscheme
rename to Core/.swiftpm/xcode/xcshareddata/xcschemes/HostApp.xcscheme
index 3bb0323b..25654d7d 100644
--- a/Tool/.swiftpm/xcode/xcshareddata/xcschemes/Tool-Package.xcscheme
+++ b/Core/.swiftpm/xcode/xcshareddata/xcschemes/HostApp.xcscheme
@@ -1,10 +1,11 @@
+ buildImplicitDependencies = "YES"
+ buildArchitectures = "Automatic">
@@ -49,9 +50,9 @@
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: "0.5.1"),
+ .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.0.0"),
.package(
url: "https://github.com/pointfreeco/swift-composable-architecture",
- from: "0.55.0"
+ 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(
@@ -126,20 +78,28 @@ let package = Package(
"PromptToCodeService",
"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",
])
@@ -149,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"),
]
),
@@ -168,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"),
@@ -184,28 +144,23 @@ let package = Package(
.target(
name: "SuggestionService",
dependencies: [
- .product(name: "SuggestionModel", package: "Tool"),
- .product(name: "SuggestionProvider", package: "Tool")
+ .product(name: "UserDefaultsObserver", package: "Tool"),
+ .product(name: "Preferences", 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"),
@@ -220,29 +175,27 @@ let package = Package(
.target(
name: "ChatService",
dependencies: [
- "ChatPlugin",
-
- // plugins
- "MathChatPlugin",
- "SearchChatPlugin",
- "ShortcutChatPlugin",
+ "LegacyChatPlugin",
// context collectors
"WebChatContextCollector",
"SystemInfoChatContextCollector",
.product(name: "ChatContextCollector", package: "Tool"),
+ .product(name: "PromptToCode", package: "Tool"),
.product(name: "AppMonitoring", package: "Tool"),
- .product(name: "Parsing", package: "swift-parsing"),
.product(name: "OpenAIService", package: "Tool"),
.product(name: "Preferences", package: "Tool"),
+ .product(name: "CustomCommandTemplateProcessor", package: "Tool"),
+ .product(name: "ChatPlugins", package: "ChatPlugins"),
+ .product(name: "Parsing", package: "swift-parsing"),
].pro([
"ProService",
])
),
.testTarget(name: "ChatServiceTests", dependencies: ["ChatService"]),
.target(
- name: "ChatPlugin",
+ name: "LegacyChatPlugin",
dependencies: [
.product(name: "AppMonitoring", package: "Tool"),
.product(name: "OpenAIService", package: "Tool"),
@@ -271,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"),
@@ -278,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"),
@@ -319,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 9b7f2ca8..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
}
}
@@ -54,16 +59,20 @@ private var isPreview: Bool {
ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
}
-struct Chat: ReducerProtocol {
+@Reducer
+struct Chat {
public typealias MessageID = String
+ @ObservableState
struct State: Equatable {
var title: String = "Chat"
- @BindingState var typedMessage = ""
+ var typedMessage = ""
var history: [DisplayedChatMessage] = []
- @BindingState var isReceivingMessage = false
+ var isReceivingMessage = false
var chatMenu = ChatMenu.State()
- @BindingState var focusedField: Field?
+ var focusedField: Field?
+ var isEnabled = true
+ var isPinnedToBottom = true
enum Field: String, Hashable {
case textField
@@ -75,6 +84,7 @@ struct Chat: ReducerProtocol {
case appear
case refresh
+ case setIsEnabled(Bool)
case sendButtonTapped
case returnButtonTapped
case stopRespondingButtonTapped
@@ -82,6 +92,8 @@ struct Chat: ReducerProtocol {
case deleteMessageButtonTapped(MessageID)
case resendMessageButtonTapped(MessageID)
case setAsExtraPromptButtonTapped(MessageID)
+ case manuallyScrolledUp
+ case scrollToBottomButtonTapped
case focusOnTextField
case referenceClicked(DisplayedChatMessage.Reference)
@@ -113,12 +125,10 @@ struct Chat: ReducerProtocol {
case sendMessage(UUID)
}
- @Dependency(\.openURL) var openURL
-
- var body: some ReducerProtocol {
+ var body: some ReducerOf {
BindingReducer()
- Scope(state: \.chatMenu, action: /Action.chatMenu) {
+ Scope(state: \.chatMenu, action: \.chatMenu) {
ChatMenu(service: service)
}
@@ -141,6 +151,10 @@ struct Chat: ReducerProtocol {
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
@@ -191,18 +205,26 @@ struct Chat: ReducerProtocol {
"/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
@@ -229,7 +251,7 @@ struct Chat: ReducerProtocol {
let debouncedHistoryChange = TimedDebounceFunction(duration: 0.2) {
await send(.historyChanged)
}
-
+
for await _ in stream {
await debouncedHistoryChange()
}
@@ -320,15 +342,7 @@ struct Chat: ReducerProtocol {
}
}(),
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 ?? [] {
@@ -363,6 +377,9 @@ struct Chat: ReducerProtocol {
case .isReceivingMessageChanged:
state.isReceivingMessage = service.isReceivingMessage
+ if service.isReceivingMessage {
+ state.isPinnedToBottom = true
+ }
return .none
case .systemPromptChanged:
@@ -387,7 +404,9 @@ struct Chat: ReducerProtocol {
}
}
-struct ChatMenu: ReducerProtocol {
+@Reducer
+struct ChatMenu {
+ @ObservableState
struct State: Equatable {
var systemPrompt: String = ""
var extraSystemPrompt: String = ""
@@ -409,7 +428,7 @@ struct ChatMenu: ReducerProtocol {
let service: ChatService
- var body: some ReducerProtocol {
+ var body: some ReducerOf {
Reduce { state, action in
switch action {
case .appear:
@@ -478,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 e6a3b2c4..9114a5dd 100644
--- a/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift
+++ b/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift
@@ -8,8 +8,8 @@ struct ChatTabItemView: View {
let chat: StoreOf
var body: some View {
- WithViewStore(chat, observe: \.title) { viewStore in
- Text(viewStore.state)
+ WithPerceptionTracking {
+ Text(chat.title)
}
}
}
@@ -22,46 +22,44 @@ struct ChatContextMenu: View {
@AppStorage(\.chatGPTTemperature) var defaultTemperature
var body: some View {
- currentSystemPrompt
- .onAppear { store.send(.appear) }
- currentExtraSystemPrompt
- resetPrompt
+ WithPerceptionTracking {
+ currentSystemPrompt
+ .onAppear { store.send(.appear) }
+ currentExtraSystemPrompt
+ resetPrompt
- Divider()
+ Divider()
- chatModel
- temperature
- defaultScopes
+ chatModel
+ temperature
+ defaultScopes
- Divider()
+ Divider()
- customCommandMenu
+ customCommandMenu
+ }
}
@ViewBuilder
var currentSystemPrompt: some View {
Text("System Prompt:")
- WithViewStore(store, observe: \.systemPrompt) { viewStore in
- Text({
- var text = viewStore.state
- if text.isEmpty { text = "N/A" }
- if text.count > 30 { text = String(text.prefix(30)) + "..." }
- return text
- }() as String)
- }
+ Text({
+ var text = store.systemPrompt
+ if text.isEmpty { text = "N/A" }
+ if text.count > 30 { text = String(text.prefix(30)) + "..." }
+ return text
+ }() as String)
}
@ViewBuilder
var currentExtraSystemPrompt: some View {
Text("Extra Prompt:")
- WithViewStore(store, observe: \.extraSystemPrompt) { viewStore in
- Text({
- var text = viewStore.state
- if text.isEmpty { text = "N/A" }
- if text.count > 30 { text = String(text.prefix(30)) + "..." }
- return text
- }() as String)
- }
+ Text({
+ var text = store.extraSystemPrompt
+ if text.isEmpty { text = "N/A" }
+ if text.count > 30 { text = String(text.prefix(30)) + "..." }
+ return text
+ }() as String)
}
var resetPrompt: some View {
@@ -72,47 +70,52 @@ 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") {
- WithViewStore(store, observe: \.chatModelIdOverride) { viewStore in
- Button(action: {
- viewStore.send(.chatModelIdOverrideSelected(nil))
- }) {
- HStack {
- if let defaultModel = chatModels
- .first(where: { $0.id == defaultChatModelId })
- {
- Text("Default (\(defaultModel.name))")
- if viewStore.state == nil {
- Image(systemName: "checkmark")
- }
- } else {
- Text("No Model Available")
+ Button(action: {
+ store.send(.chatModelIdOverrideSelected(nil))
+ }) {
+ HStack {
+ if let defaultModel = allModels
+ .first(where: { $0.id == defaultChatModelId })
+ {
+ Text("Default (\(defaultModel.name))")
+ if store.chatModelIdOverride == nil {
+ Image(systemName: "checkmark")
}
+ } else {
+ Text("No Model Available")
}
}
+ }
- if let id = viewStore.state, !chatModels.map(\.id).contains(id) {
- Button(action: {
- viewStore.send(.chatModelIdOverrideSelected(nil))
- }) {
- HStack {
- Text("Default (Selected Model Not Found)")
- Image(systemName: "checkmark")
- }
+ if let id = store.chatModelIdOverride, !allModels.map(\.id).contains(id) {
+ Button(action: {
+ store.send(.chatModelIdOverrideSelected(nil))
+ }) {
+ HStack {
+ Text("Default (Selected Model Not Found)")
+ Image(systemName: "checkmark")
}
}
+ }
- Divider()
-
- ForEach(chatModels, id: \.id) { model in
- Button(action: {
- viewStore.send(.chatModelIdOverrideSelected(model.id))
- }) {
- HStack {
- Text(model.name)
- if model.id == viewStore.state {
- Image(systemName: "checkmark")
- }
+ Divider()
+
+ ForEach(allModels, id: \.id) { model in
+ Button(action: {
+ store.send(.chatModelIdOverrideSelected(model.id))
+ }) {
+ HStack {
+ Text(model.name)
+ if model.id == store.chatModelIdOverride {
+ Image(systemName: "checkmark")
}
}
}
@@ -123,34 +126,32 @@ struct ChatContextMenu: View {
@ViewBuilder
var temperature: some View {
Menu("Temperature") {
- WithViewStore(store, observe: \.temperatureOverride) { viewStore in
+ Button(action: {
+ store.send(.temperatureOverrideSelected(nil))
+ }) {
+ HStack {
+ Text(
+ "Default (\(defaultTemperature.formatted(.number.precision(.fractionLength(1)))))"
+ )
+ if store.temperatureOverride == nil {
+ Image(systemName: "checkmark")
+ }
+ }
+ }
+
+ Divider()
+
+ ForEach(Array(stride(from: 0.0, through: 2.0, by: 0.1)), id: \.self) { value in
Button(action: {
- viewStore.send(.temperatureOverrideSelected(nil))
+ store.send(.temperatureOverrideSelected(value))
}) {
HStack {
- Text(
- "Default (\(defaultTemperature.formatted(.number.precision(.fractionLength(1)))))"
- )
- if viewStore.state == nil {
+ Text("\(value.formatted(.number.precision(.fractionLength(1))))")
+ if value == store.temperatureOverride {
Image(systemName: "checkmark")
}
}
}
-
- Divider()
-
- ForEach(Array(stride(from: 0.0, through: 2.0, by: 0.1)), id: \.self) { value in
- Button(action: {
- viewStore.send(.temperatureOverrideSelected(value))
- }) {
- HStack {
- Text("\(value.formatted(.number.precision(.fractionLength(1))))")
- if value == viewStore.state {
- Image(systemName: "checkmark")
- }
- }
- }
- }
}
}
}
@@ -158,24 +159,22 @@ struct ChatContextMenu: View {
@ViewBuilder
var defaultScopes: some View {
Menu("Default Scopes") {
- WithViewStore(store, observe: \.defaultScopes) { viewStore in
+ 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: {
- viewStore.send(.toggleScope(value))
- }) {
- HStack {
- Text("@" + value.rawValue)
- if viewStore.state.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 db14b5d3..ad2c6887 100644
--- a/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift
+++ b/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift
@@ -4,6 +4,7 @@ import ChatTab
import CodableWrappers
import Combine
import ComposableArchitecture
+import DebounceFunction
import Foundation
import OpenAIService
import Preferences
@@ -15,8 +16,9 @@ public class ChatGPTChatTab: ChatTab {
public let service: ChatService
let chat: StoreOf
- let viewStore: ViewStoreOf
private var cancellable = Set()
+ private var observer = NSObject()
+ private let updateContentDebounce = DebounceRunner(duration: 0.5)
struct RestorableState: Codable {
var history: [OpenAIService.ChatMessage]
@@ -50,8 +52,8 @@ public class ChatGPTChatTab: ChatTab {
}
public func buildIcon() -> any View {
- WithViewStore(chat, observe: \.isReceivingMessage) { viewStore in
- if viewStore.state {
+ WithPerceptionTracking {
+ if self.chat.isReceivingMessage {
Image(systemName: "ellipsis.message")
} else {
Image(systemName: "message")
@@ -60,7 +62,7 @@ public class ChatGPTChatTab: ChatTab {
}
public func buildMenu() -> any View {
- ChatContextMenu(store: chat.scope(state: \.chatMenu, action: Chat.Action.chatMenu))
+ ChatContextMenu(store: chat.scope(state: \.chatMenu, action: \.chatMenu))
}
public func restorableState() async -> Data {
@@ -74,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
@@ -89,12 +88,12 @@ public class ChatGPTChatTab: ChatTab {
await tab.service.memory.mutateHistory { history in
history = state.history
}
- tab.viewStore.send(.refresh)
+ tab.chat.send(.refresh)
}
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 {
@@ -103,52 +102,87 @@ 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
public init(service: ChatService = .init(), store: StoreOf) {
self.service = service
- chat = .init(initialState: .init(), reducer: Chat(service: service))
- viewStore = .init(chat)
+ chat = .init(initialState: .init(), reducer: { Chat(service: service) })
super.init(store: store)
}
public func start() {
- chatTabViewStore.send(.updateTitle("Chat"))
+ observer = .init()
+ cancellable = []
- chatTabViewStore.publisher.focusTrigger.removeDuplicates().sink { [weak self] _ in
- Task { @MainActor [weak self] in
- self?.viewStore.send(.focusOnTextField)
- }
- }.store(in: &cancellable)
+ chatTabStore.send(.updateTitle("Chat"))
service.$systemPrompt.removeDuplicates().sink { [weak self] _ in
Task { @MainActor [weak self] in
- self?.chatTabViewStore.send(.tabContentUpdated)
+ self?.chatTabStore.send(.tabContentUpdated)
}
}.store(in: &cancellable)
service.$extraSystemPrompt.removeDuplicates().sink { [weak self] _ in
Task { @MainActor [weak self] in
- self?.chatTabViewStore.send(.tabContentUpdated)
+ self?.chatTabStore.send(.tabContentUpdated)
}
}.store(in: &cancellable)
- viewStore.publisher.map(\.title).removeDuplicates().sink { [weak self] title in
- Task { @MainActor [weak self] in
- self?.chatTabViewStore.send(.updateTitle(title))
+ Task { @MainActor in
+ var lastTrigger = -1
+ observer.observe { [weak self] in
+ guard let self else { return }
+ let trigger = chatTabStore.focusTrigger
+ guard lastTrigger != trigger else { return }
+ lastTrigger = trigger
+ Task { @MainActor [weak self] in
+ self?.chat.send(.focusOnTextField)
+ }
}
- }.store(in: &cancellable)
+ }
- viewStore.publisher.removeDuplicates().debounce(
- for: .milliseconds(500),
- scheduler: DispatchQueue.main
- ).sink { [weak self] _ in
- Task { @MainActor [weak self] in
- self?.chatTabViewStore.send(.tabContentUpdated)
+ Task { @MainActor in
+ var lastTitle = ""
+ observer.observe { [weak self] in
+ guard let self else { return }
+ let title = self.chatTabStore.state.title
+ guard lastTitle != title else { return }
+ lastTitle = title
+ Task { @MainActor [weak self] in
+ self?.chatTabStore.send(.updateTitle(title))
+ }
}
- }.store(in: &cancellable)
+ }
+
+ 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 db080dac..9210a05d 100644
--- a/Core/Sources/ChatGPTChatTab/ChatPanel.swift
+++ b/Core/Sources/ChatGPTChatTab/ChatPanel.swift
@@ -18,7 +18,7 @@ public struct ChatPanel: View {
Divider()
ChatPanelInputArea(chat: chat)
}
- .background(.clear)
+ .background(Color(nsColor: .windowBackgroundColor))
.onAppear { chat.send(.appear) }
}
}
@@ -43,120 +43,129 @@ 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
@State var scrollOffset: Double = 0
@State var listHeight: Double = 0
+ @State var didScrollToBottomOnAppearOnce = false
+ @State var isBottomHidden = true
@Environment(\.isEnabled) var isEnabled
var body: some View {
- ScrollViewReader { proxy in
- GeometryReader { listGeo in
- List {
- Group {
- Spacer(minLength: 12)
-
- Instruction(chat: chat)
-
- ChatHistory(chat: chat)
- .listItemTint(.clear)
-
- WithViewStore(chat, observe: \.isReceivingMessage) { viewStore in
- if viewStore.state {
- Spacer(minLength: 12)
- }
+ WithPerceptionTracking {
+ ScrollViewReader { proxy in
+ GeometryReader { listGeo in
+ List {
+ Group {
+ Spacer(minLength: 12)
+ .id(topID)
+
+ Instruction(chat: chat)
+
+ ChatHistory(chat: chat)
+ .listItemTint(.clear)
+
+ ExtraSpacingInResponding(chat: chat)
+
+ Spacer(minLength: 12)
+ .id(bottomID)
+ .onAppear {
+ isBottomHidden = false
+ if !didScrollToBottomOnAppearOnce {
+ proxy.scrollTo(bottomID, anchor: .bottom)
+ didScrollToBottomOnAppearOnce = true
+ }
+ }
+ .onDisappear {
+ isBottomHidden = true
+ }
+ .background(GeometryReader { geo in
+ let offset = geo.frame(in: .named(scrollSpace)).minY
+ Color.clear.preference(
+ key: ScrollViewOffsetPreferenceKey.self,
+ value: offset
+ )
+ })
}
-
- Spacer(minLength: 12)
- .id(bottomID)
- .onAppear {
- proxy.scrollTo(bottomID, anchor: .bottom)
- }
- .task {
- proxy.scrollTo(bottomID, anchor: .bottom)
+ .modify { view in
+ if #available(macOS 13.0, *) {
+ view
+ .listRowSeparator(.hidden)
+ .listSectionSeparator(.hidden)
+ } else {
+ view
}
- .background(GeometryReader { geo in
- let offset = geo.frame(in: .named(scrollSpace)).minY
- Color.clear.preference(
- key: ScrollViewOffsetPreferenceKey.self,
- value: offset
- )
- })
+ }
}
+ .listStyle(.plain)
+ .listRowBackground(EmptyView())
.modify { view in
if #available(macOS 13.0, *) {
- view
- .listRowSeparator(.hidden)
- .listSectionSeparator(.hidden)
+ view.scrollContentBackground(.hidden)
} else {
view
}
}
- }
- .listStyle(.plain)
- .listRowBackground(EmptyView())
- .modify { view in
- if #available(macOS 13.0, *) {
- view.scrollContentBackground(.hidden)
- } else {
- view
+ .coordinateSpace(name: scrollSpace)
+ .preference(
+ key: ListHeightPreferenceKey.self,
+ value: listGeo.size.height
+ )
+ .onPreferenceChange(ListHeightPreferenceKey.self) { value in
+ listHeight = value
+ updatePinningState()
}
- }
- .coordinateSpace(name: scrollSpace)
- .preference(
- key: ListHeightPreferenceKey.self,
- value: listGeo.size.height
- )
- .onPreferenceChange(ListHeightPreferenceKey.self) { value in
- listHeight = value
- updatePinningState()
- }
- .onPreferenceChange(ScrollViewOffsetPreferenceKey.self) { value in
- scrollOffset = value
- updatePinningState()
- }
- .overlay(alignment: .bottom) {
- WithViewStore(chat, observe: \.isReceivingMessage) { viewStore in
+ .onPreferenceChange(ScrollViewOffsetPreferenceKey.self) { value in
+ scrollOffset = value
+ updatePinningState()
+ }
+ .overlay(alignment: .bottom) {
StopRespondingButton(chat: chat)
- .padding(.bottom, 8)
- .opacity(viewStore.state ? 1 : 0)
- .disabled(!viewStore.state)
- .transformEffect(.init(translationX: 0, y: viewStore.state ? 0 : 20))
}
- }
- .overlay(alignment: .bottomTrailing) {
- scrollToBottomButton(proxy: proxy)
- }
- .background {
- PinToBottomHandler(chat: chat, pinnedToBottom: $isPinnedToBottom) {
+ .overlay(alignment: .bottomTrailing) {
+ scrollToBottomButton(proxy: proxy)
+ }
+ .background {
+ PinToBottomHandler(chat: chat, isBottomHidden: isBottomHidden) {
+ proxy.scrollTo(bottomID, anchor: .bottom)
+ }
+ }
+ .onAppear {
+ proxy.scrollTo(bottomID, anchor: .bottom)
+ }
+ .task {
proxy.scrollTo(bottomID, anchor: .bottom)
}
}
}
- }
- .onAppear {
- trackScrollWheel()
- }
- .onDisappear {
- cancellable.forEach { $0.cancel() }
- cancellable = []
+ .onAppear {
+ trackScrollWheel()
+ }
+ .onDisappear {
+ 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)
@@ -174,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)
}
@@ -197,43 +206,57 @@ struct ChatPanelMessages: View {
.buttonStyle(.plain)
}
+ struct ExtraSpacingInResponding: View {
+ let chat: StoreOf
+
+ var body: some View {
+ WithPerceptionTracking {
+ if chat.isReceivingMessage {
+ Spacer(minLength: 12)
+ }
+ }
+ }
+ }
+
struct PinToBottomHandler: View {
let chat: StoreOf
- @Binding var pinnedToBottom: Bool
+ let isBottomHidden: Bool
let scrollToBottom: () -> Void
@State var isInitialLoad = true
- struct PinToBottomRelatedState: Equatable {
- var isReceivingMessage: Bool
- var lastMessage: DisplayedChatMessage?
- }
-
var body: some View {
- WithViewStore(chat, observe: {
- PinToBottomRelatedState(
- isReceivingMessage: $0.isReceivingMessage,
- lastMessage: $0.history.last
- )
- }) { viewStore in
+ WithPerceptionTracking {
EmptyView()
- .onChange(of: viewStore.state.isReceivingMessage) { isReceiving in
+ .onChange(of: chat.isReceivingMessage) { isReceiving in
if isReceiving {
- pinnedToBottom = true
- scrollToBottom()
+ Task {
+ await Task.yield()
+ withAnimation(.easeInOut(duration: 0.1)) {
+ scrollToBottom()
+ }
+ }
}
}
- .onChange(of: viewStore.state.lastMessage) { _ in
- if pinnedToBottom || isInitialLoad {
+ .onChange(of: chat.history.last) { _ in
+ if chat.withState(\.isPinnedToBottom) || isInitialLoad {
if isInitialLoad {
isInitialLoad = false
}
Task {
await Task.yield()
- scrollToBottom()
+ withAnimation(.easeInOut(duration: 0.1)) {
+ scrollToBottom()
+ }
}
}
}
+ .onChange(of: isBottomHidden) { value in
+ // This is important to prevent it from jumping to the top!
+ if value, chat.withState(\.isPinnedToBottom) {
+ scrollToBottom()
+ }
+ }
}
}
}
@@ -243,67 +266,97 @@ struct ChatHistory: View {
let chat: StoreOf
var body: some View {
- WithViewStore(chat, observe: \.history) { viewStore in
- ForEach(viewStore.state, id: \.id) { message in
- let text = message.text
-
- 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)
- case .assistant:
- BotMessage(
- id: message.id,
- text: text,
- references: message.references,
- chat: chat
- )
- .listRowInsets(EdgeInsets(
- top: 0,
- leading: -8,
- bottom: 0,
- trailing: -8
- ))
- .padding(.vertical, 4)
- case .tool:
- FunctionMessage(id: message.id, text: text)
- case .ignored:
- EmptyView()
+ WithPerceptionTracking {
+ ForEach(chat.history, id: \.id) { message in
+ WithPerceptionTracking {
+ ChatHistoryItem(chat: chat, message: message).id(message.id)
}
}
}
}
}
-private struct StopRespondingButton: View {
+struct ChatHistoryItem: View {
let chat: StoreOf
+ let message: DisplayedChatMessage
var body: some View {
- Button(action: {
- chat.send(.stopRespondingButtonTapped)
- }) {
- HStack(spacing: 4) {
- Image(systemName: "stop.fill")
- Text("Stop Responding")
+ WithPerceptionTracking {
+ let text = message.text
+ let markdownContent = message.markdownContent
+ switch message.role {
+ case .user:
+ 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
+ )
+ .listRowInsets(EdgeInsets(
+ top: 0,
+ leading: -8,
+ bottom: 0,
+ trailing: -8
+ ))
+ .padding(.vertical, 4)
+ case .tool:
+ FunctionMessage(id: message.id, text: text)
+ case .ignored:
+ EmptyView()
}
- .padding(8)
- .background(
- .regularMaterial,
- in: RoundedRectangle(cornerRadius: r, style: .continuous)
- )
- .overlay {
- RoundedRectangle(cornerRadius: r, style: .continuous)
- .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
+ }
+ }
+}
+
+private struct StopRespondingButton: View {
+ let chat: StoreOf
+
+ var body: some View {
+ WithPerceptionTracking {
+ if chat.isReceivingMessage {
+ Button(action: {
+ chat.send(.stopRespondingButtonTapped)
+ }) {
+ HStack(spacing: 4) {
+ Image(systemName: "stop.fill")
+ Text("Stop Responding")
+ }
+ .padding(8)
+ .background(
+ .regularMaterial,
+ in: RoundedRectangle(cornerRadius: r, style: .continuous)
+ )
+ .overlay {
+ RoundedRectangle(cornerRadius: r, style: .continuous)
+ .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
+ }
+ }
+ .buttonStyle(.borderless)
+ .frame(maxWidth: .infinity, alignment: .center)
+ .padding(.bottom, 8)
+ .opacity(chat.isReceivingMessage ? 1 : 0)
+ .disabled(!chat.isReceivingMessage)
+ .transformEffect(.init(
+ translationX: 0,
+ y: chat.isReceivingMessage ? 0 : 20
+ ))
}
}
- .buttonStyle(.borderless)
- .frame(maxWidth: .infinity, alignment: .center)
}
}
@@ -314,7 +367,7 @@ struct ChatPanelInputArea: View {
var body: some View {
HStack {
clearButton
- textEditor
+ InputAreaTextEditor(chat: chat, focusedField: $focusedField)
}
.padding(8)
.background(.ultraThickMaterial)
@@ -343,89 +396,86 @@ struct ChatPanelInputArea: View {
.buttonStyle(.plain)
}
- @MainActor
- var textEditor: some View {
- HStack(spacing: 0) {
- WithViewStore(
- chat,
- removeDuplicates: {
- $0.typedMessage == $1.typedMessage && $0.focusedField == $1.focusedField
+ struct InputAreaTextEditor: View {
+ @Perception.Bindable var chat: StoreOf
+ var focusedField: FocusState.Binding
+
+ var body: some View {
+ WithPerceptionTracking {
+ HStack(spacing: 0) {
+ AutoresizingCustomTextEditor(
+ text: $chat.typedMessage,
+ font: .systemFont(ofSize: 14),
+ isEditable: true,
+ maxHeight: 400,
+ onSubmit: { chat.send(.sendButtonTapped) },
+ completions: chatAutoCompletion
+ )
+ .focused(focusedField, equals: .textField)
+ .bind($chat.focusedField, to: focusedField)
+ .padding(8)
+ .fixedSize(horizontal: false, vertical: true)
+
+ Button(action: {
+ chat.send(.sendButtonTapped)
+ }) {
+ Image(systemName: "paperplane.fill")
+ .padding(8)
+ }
+ .buttonStyle(.plain)
+ .disabled(chat.isReceivingMessage)
+ .keyboardShortcut(KeyEquivalent.return, modifiers: [])
}
- ) { viewStore in
- AutoresizingCustomTextEditor(
- text: viewStore.$typedMessage,
- font: .systemFont(ofSize: 14),
- isEditable: true,
- maxHeight: 400,
- onSubmit: { viewStore.send(.sendButtonTapped) },
- completions: chatAutoCompletion
- )
- .focused($focusedField, equals: .textField)
- .bind(viewStore.$focusedField, to: $focusedField)
- .padding(8)
- .fixedSize(horizontal: false, vertical: true)
- }
+ .frame(maxWidth: .infinity)
+ .background {
+ RoundedRectangle(cornerRadius: 6)
+ .fill(Color(nsColor: .controlBackgroundColor))
+ }
+ .overlay {
+ RoundedRectangle(cornerRadius: 6)
+ .stroke(Color(nsColor: .controlColor), lineWidth: 1)
+ }
+ .background {
+ Button(action: {
+ chat.send(.returnButtonTapped)
+ }) {
+ EmptyView()
+ }
+ .keyboardShortcut(KeyEquivalent.return, modifiers: [.shift])
- WithViewStore(chat, observe: \.isReceivingMessage) { viewStore in
- Button(action: {
- viewStore.send(.sendButtonTapped)
- }) {
- Image(systemName: "paperplane.fill")
- .padding(8)
+ Button(action: {
+ focusedField.wrappedValue = .textField
+ }) {
+ EmptyView()
+ }
+ .keyboardShortcut("l", modifiers: [.command])
}
- .buttonStyle(.plain)
- .disabled(viewStore.state)
- .keyboardShortcut(KeyEquivalent.return, modifiers: [])
}
}
- .frame(maxWidth: .infinity)
- .background {
- RoundedRectangle(cornerRadius: 6)
- .fill(Color(nsColor: .controlBackgroundColor))
- }
- .overlay {
- RoundedRectangle(cornerRadius: 6)
- .stroke(Color(nsColor: .controlColor), lineWidth: 1)
- }
- .background {
- Button(action: {
- chat.send(.returnButtonTapped)
- }) {
- EmptyView()
- }
- .keyboardShortcut(KeyEquivalent.return, modifiers: [.shift])
- Button(action: {
- focusedField = .textField
- }) {
- EmptyView()
- }
- .keyboardShortcut("l", modifiers: [.command])
- }
- }
+ func chatAutoCompletion(text: String, proposed: [String], range: NSRange) -> [String] {
+ guard text.count == 1 else { return [] }
+ let plugins = [String]() // chat.pluginIdentifiers.map { "/\($0)" }
+ let availableFeatures = plugins + [
+ "/exit",
+ "@code",
+ "@sense",
+ "@project",
+ "@web",
+ ]
- func chatAutoCompletion(text: String, proposed: [String], range: NSRange) -> [String] {
- guard text.count == 1 else { return [] }
- let plugins = [String]() // chat.pluginIdentifiers.map { "/\($0)" }
- let availableFeatures = plugins + [
- "/exit",
- "@code",
- "@sense",
- "@project",
- "@web",
- ]
-
- let result: [String] = availableFeatures
- .filter { $0.hasPrefix(text) && $0 != text }
- .compactMap {
- guard let index = $0.index(
- $0.startIndex,
- offsetBy: range.location,
- limitedBy: $0.endIndex
- ) else { return nil }
- return String($0[index...])
- }
- return result
+ let result: [String] = availableFeatures
+ .filter { $0.hasPrefix(text) && $0 != text }
+ .compactMap {
+ guard let index = $0.index(
+ $0.startIndex,
+ offsetBy: range.location,
+ limitedBy: $0.endIndex
+ ) else { return nil }
+ return String($0[index...])
+ }
+ return result
+ }
}
}
@@ -454,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)
),
]
),
@@ -512,7 +562,7 @@ struct ChatPanel_Preview: PreviewProvider {
static var previews: some View {
ChatPanel(chat: .init(
initialState: .init(history: ChatPanel_Preview.history, isReceivingMessage: true),
- reducer: Chat(service: .init())
+ reducer: { Chat(service: .init()) }
))
.frame(width: 450, height: 1200)
.colorScheme(.dark)
@@ -522,8 +572,8 @@ struct ChatPanel_Preview: PreviewProvider {
struct ChatPanel_EmptyChat_Preview: PreviewProvider {
static var previews: some View {
ChatPanel(chat: .init(
- initialState: .init(history: [], isReceivingMessage: false),
- reducer: Chat(service: .init())
+ initialState: .init(history: [DisplayedChatMessage](), isReceivingMessage: false),
+ reducer: { Chat(service: .init()) }
))
.padding()
.frame(width: 450, height: 600)
@@ -531,34 +581,11 @@ struct ChatPanel_EmptyChat_Preview: PreviewProvider {
}
}
-struct ChatCodeSyntaxHighlighter: CodeSyntaxHighlighter {
- let brightMode: Bool
- let font: NSFont
- let colorChange: Color?
-
- init(brightMode: Bool, font: NSFont, colorChange: Color?) {
- self.brightMode = brightMode
- self.font = font
- self.colorChange = colorChange
- }
-
- func highlightCode(_ content: String, language: String?) -> Text {
- let content = highlightedCodeBlock(
- code: content,
- language: language ?? "",
- scenario: "chat",
- brightMode: brightMode,
- font: font
- )
- return Text(AttributedString(content))
- }
-}
-
struct ChatPanel_InputText_Preview: PreviewProvider {
static var previews: some View {
ChatPanel(chat: .init(
initialState: .init(history: ChatPanel_Preview.history, isReceivingMessage: false),
- reducer: Chat(service: .init())
+ reducer: { Chat(service: .init()) }
))
.padding()
.frame(width: 450, height: 600)
@@ -576,7 +603,7 @@ struct ChatPanel_InputMultilineText_Preview: PreviewProvider {
history: ChatPanel_Preview.history,
isReceivingMessage: false
),
- reducer: Chat(service: .init())
+ reducer: { Chat(service: .init()) }
)
)
.padding()
@@ -589,7 +616,7 @@ struct ChatPanel_Light_Preview: PreviewProvider {
static var previews: some View {
ChatPanel(chat: .init(
initialState: .init(history: ChatPanel_Preview.history, isReceivingMessage: true),
- reducer: Chat(service: .init())
+ reducer: { Chat(service: .init()) }
))
.padding()
.frame(width: 450, height: 600)
diff --git a/Core/Sources/ChatGPTChatTab/CodeBlockHighlighter.swift b/Core/Sources/ChatGPTChatTab/CodeBlockHighlighter.swift
new file mode 100644
index 00000000..0e506b96
--- /dev/null
+++ b/Core/Sources/ChatGPTChatTab/CodeBlockHighlighter.swift
@@ -0,0 +1,117 @@
+import Combine
+import ComposableArchitecture
+import DebounceFunction
+import Foundation
+import MarkdownUI
+import Perception
+import SharedUIComponents
+import SwiftUI
+
+/// Use this instead of the built in ``CodeBlockView`` to highlight code blocks asynchronously,
+/// so that the UI doesn't freeze when rendering large code blocks.
+struct AsyncCodeBlockView: View {
+ @Perceptible
+ class Storage {
+ static let queue = DispatchQueue(
+ label: "chat-code-block-highlight",
+ qos: .userInteractive,
+ attributes: .concurrent
+ )
+
+ var highlighted: AttributedString?
+ @PerceptionIgnored var debounceFunction: DebounceFunction?
+ @PerceptionIgnored private var highlightTask: Task?
+
+ init() {
+ debounceFunction = .init(duration: 0.5, block: { [weak self] view in
+ self?.highlight(for: view)
+ })
+ }
+
+ func highlight(debounce: Bool, for view: AsyncCodeBlockView) {
+ if debounce {
+ Task { await debounceFunction?(view) }
+ } else {
+ highlight(for: view)
+ }
+ }
+
+ func highlight(for view: AsyncCodeBlockView) {
+ highlightTask?.cancel()
+ let content = view.content
+ let language = view.fenceInfo ?? ""
+ let brightMode = view.colorScheme != .dark
+ let font = CodeHighlighting.SendableFont(font: view.font)
+ highlightTask = Task {
+ let string = await withUnsafeContinuation { continuation in
+ Self.queue.async {
+ let content = CodeHighlighting.highlightedCodeBlock(
+ code: content,
+ language: language,
+ scenario: "chat",
+ brightMode: brightMode,
+ font:font
+ )
+ continuation.resume(returning: AttributedString(content))
+ }
+ }
+ try Task.checkCancellation()
+ await MainActor.run {
+ self.highlighted = string
+ }
+ }
+ }
+ }
+
+ let fenceInfo: String?
+ let content: String
+ let font: NSFont
+
+ @Environment(\.colorScheme) var colorScheme
+ @State var storage = Storage()
+ @AppStorage(\.syncChatCodeHighlightTheme) var syncCodeHighlightTheme
+ @AppStorage(\.codeForegroundColorLight) var codeForegroundColorLight
+ @AppStorage(\.codeBackgroundColorLight) var codeBackgroundColorLight
+ @AppStorage(\.codeForegroundColorDark) var codeForegroundColorDark
+ @AppStorage(\.codeBackgroundColorDark) var codeBackgroundColorDark
+
+ init(fenceInfo: String?, content: String, font: NSFont) {
+ self.fenceInfo = fenceInfo
+ self.content = content.hasSuffix("\n") ? String(content.dropLast()) : content
+ self.font = font
+ }
+
+ var body: some View {
+ WithPerceptionTracking {
+ Group {
+ if let highlighted = storage.highlighted {
+ Text(highlighted)
+ } else {
+ Text(content).font(.init(font))
+ }
+ }
+ .onAppear {
+ storage.highlight(debounce: false, for: self)
+ }
+ .onChange(of: colorScheme) { _ in
+ storage.highlight(debounce: false, for: self)
+ }
+ .onChange(of: syncCodeHighlightTheme) { _ in
+ storage.highlight(debounce: true, for: self)
+ }
+ .onChange(of: codeForegroundColorLight) { _ in
+ storage.highlight(debounce: true, for: self)
+ }
+ .onChange(of: codeBackgroundColorLight) { _ in
+ storage.highlight(debounce: true, for: self)
+ }
+ .onChange(of: codeForegroundColorDark) { _ in
+ storage.highlight(debounce: true, for: self)
+ }
+ .onChange(of: codeBackgroundColorDark) { _ in
+ storage.highlight(debounce: true, for: self)
+ }
+ }
+ }
+}
+
diff --git a/Core/Sources/ChatGPTChatTab/Styles.swift b/Core/Sources/ChatGPTChatTab/Styles.swift
index e654b72e..6c117c9a 100644
--- a/Core/Sources/ChatGPTChatTab/Styles.swift
+++ b/Core/Sources/ChatGPTChatTab/Styles.swift
@@ -57,7 +57,7 @@ extension View {
HStack(alignment: .center) {
Text(configuration.language ?? "code")
.foregroundStyle(labelColor)
- .font(.callout)
+ .font(.callout.bold())
.padding(.leading, 8)
.lineLimit(1)
Spacer()
@@ -74,191 +74,6 @@ extension View {
}
}
-struct ThemedMarkdownText: View {
- @AppStorage(\.syncChatCodeHighlightTheme) var syncCodeHighlightTheme
- @AppStorage(\.codeForegroundColorLight) var codeForegroundColorLight
- @AppStorage(\.codeBackgroundColorLight) var codeBackgroundColorLight
- @AppStorage(\.codeForegroundColorDark) var codeForegroundColorDark
- @AppStorage(\.codeBackgroundColorDark) var codeBackgroundColorDark
- @AppStorage(\.chatFontSize) var chatFontSize
- @AppStorage(\.chatCodeFont) var chatCodeFont
- @Environment(\.colorScheme) var colorScheme
-
- let text: String
-
- init(_ text: String) {
- self.text = text
- }
-
- var body: some View {
- Markdown(text)
- .textSelection(.enabled)
- .markdownTheme(.custom(
- fontSize: chatFontSize,
- codeBlockBackgroundColor: {
- if syncCodeHighlightTheme {
- if colorScheme == .light, let color = codeBackgroundColorLight.value {
- return color.swiftUIColor
- } else if let color = codeBackgroundColorDark.value {
- return color.swiftUIColor
- }
- }
-
- return Color(nsColor: .textBackgroundColor).opacity(0.7)
- }(),
- codeBlockLabelColor: {
- if syncCodeHighlightTheme {
- if colorScheme == .light,
- let color = codeForegroundColorLight.value
- {
- return color.swiftUIColor.opacity(0.5)
- } else if let color = codeForegroundColorDark.value {
- return color.swiftUIColor.opacity(0.5)
- }
- }
- return Color.secondary.opacity(0.7)
- }()
- ))
- .markdownCodeSyntaxHighlighter(
- ChatCodeSyntaxHighlighter(
- brightMode: colorScheme != .dark,
- font: chatCodeFont.value.nsFont,
- colorChange: colorScheme == .dark
- ? codeForegroundColorDark.value?.swiftUIColor
- : codeForegroundColorLight.value?.swiftUIColor
- )
- )
- }
-}
-
-extension MarkdownUI.Theme {
- static func custom(
- fontSize: Double,
- codeBlockBackgroundColor: Color,
- codeBlockLabelColor: Color
- ) -> MarkdownUI.Theme {
- .gitHub.text {
- ForegroundColor(.primary)
- BackgroundColor(Color.clear)
- FontSize(fontSize)
- }
- .codeBlock { configuration in
- let wrapCode = UserDefaults.shared.value(for: \.wrapCodeInChatCodeBlock)
-
- if wrapCode {
- configuration.label
- .codeBlockLabelStyle()
- .codeBlockStyle(
- configuration,
- backgroundColor: codeBlockBackgroundColor,
- labelColor: codeBlockLabelColor
- )
- } else {
- ScrollView(.horizontal) {
- configuration.label
- .codeBlockLabelStyle()
- }
- .workaroundForVerticalScrollingBugInMacOS()
- .codeBlockStyle(
- configuration,
- backgroundColor: codeBlockBackgroundColor,
- labelColor: codeBlockLabelColor
- )
- }
- }
- }
-
- static func instruction(fontSize: Double) -> MarkdownUI.Theme {
- .gitHub.text {
- ForegroundColor(.primary)
- BackgroundColor(Color.clear)
- FontSize(fontSize)
- }
- .code {
- FontFamilyVariant(.monospaced)
- FontSize(.em(0.85))
- BackgroundColor(Color.secondary.opacity(0.2))
- }
- .codeBlock { configuration in
- let wrapCode = UserDefaults.shared.value(for: \.wrapCodeInChatCodeBlock)
-
- if wrapCode {
- configuration.label
- .codeBlockLabelStyle()
- .codeBlockStyle(
- configuration,
- backgroundColor: Color(nsColor: .textBackgroundColor).opacity(0.7),
- labelColor: Color.secondary.opacity(0.7)
- )
- } else {
- ScrollView(.horizontal) {
- configuration.label
- .codeBlockLabelStyle()
- }
- .workaroundForVerticalScrollingBugInMacOS()
- .codeBlockStyle(
- configuration,
- backgroundColor: Color(nsColor: .textBackgroundColor).opacity(0.7),
- labelColor: Color.secondary.opacity(0.7)
- )
- }
- }
- .table { configuration in
- configuration.label
- .fixedSize(horizontal: false, vertical: true)
- .markdownTableBorderStyle(.init(
- color: .init(nsColor: .separatorColor),
- strokeStyle: .init(lineWidth: 1)
- ))
- .markdownTableBackgroundStyle(
- .alternatingRows(Color.secondary.opacity(0.1), Color.secondary.opacity(0.2))
- )
- .markdownMargin(top: 0, bottom: 16)
- }
- .tableCell { configuration in
- configuration.label
- .markdownTextStyle {
- if configuration.row == 0 {
- FontWeight(.semibold)
- }
- BackgroundColor(nil)
- }
- .fixedSize(horizontal: false, vertical: true)
- .padding(.vertical, 6)
- .padding(.horizontal, 13)
- .relativeLineSpacing(.em(0.25))
- }
- }
-
- static func functionCall(fontSize: Double) -> MarkdownUI.Theme {
- .gitHub.text {
- ForegroundColor(.secondary)
- BackgroundColor(Color.clear)
- FontSize(fontSize - 1)
- }
- .list { configuration in
- configuration.label
- .markdownMargin(top: 4, bottom: 4)
- }
- .paragraph { configuration in
- configuration.label
- .markdownMargin(top: 0, bottom: 4)
- }
- .codeBlock { configuration in
- configuration.label
- .relativeLineSpacing(.em(0.225))
- .markdownTextStyle {
- FontFamilyVariant(.monospaced)
- FontSize(.em(0.85))
- }
- .padding(16)
- .background(Color(nsColor: .textBackgroundColor).opacity(0.7))
- .clipShape(RoundedRectangle(cornerRadius: 6))
- .markdownMargin(top: 4, bottom: 4)
- }
- }
-}
-
final class VerticalScrollingFixHostingView: NSHostingView where Content: View {
override func wantsForwardedScrollEvents(for axis: NSEvent.GestureAxis) -> Bool {
return axis == .vertical
diff --git a/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift b/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift
index 5d202678..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()
@@ -89,41 +90,45 @@ struct ReferenceList: View {
let chat: StoreOf
var body: some View {
- ScrollView {
- VStack(alignment: .leading, spacing: 8) {
- ForEach(0.. MarkdownUI.Theme {
+ .gitHub.text {
+ ForegroundColor(.secondary)
+ BackgroundColor(Color.clear)
+ FontSize(fontSize - 1)
+ }
+ .list { configuration in
+ configuration.label
+ .markdownMargin(top: 4, bottom: 4)
+ }
+ .paragraph { configuration in
+ configuration.label
+ .markdownMargin(top: 0, bottom: 4)
+ }
+ .codeBlock { configuration in
+ configuration.label
+ .relativeLineSpacing(.em(0.225))
+ .markdownTextStyle {
+ FontFamilyVariant(.monospaced)
+ FontSize(.em(0.85))
+ }
+ .padding(16)
+ .background(Color(nsColor: .textBackgroundColor).opacity(0.7))
+ .clipShape(RoundedRectangle(cornerRadius: 6))
+ .markdownMargin(top: 4, bottom: 4)
+ }
+ }
+}
diff --git a/Core/Sources/ChatGPTChatTab/Views/InstructionMarkdownTheme.swift b/Core/Sources/ChatGPTChatTab/Views/InstructionMarkdownTheme.swift
new file mode 100644
index 00000000..30e786ea
--- /dev/null
+++ b/Core/Sources/ChatGPTChatTab/Views/InstructionMarkdownTheme.swift
@@ -0,0 +1,68 @@
+import Foundation
+import MarkdownUI
+import SwiftUI
+
+extension MarkdownUI.Theme {
+ static func instruction(fontSize: Double) -> MarkdownUI.Theme {
+ .gitHub.text {
+ ForegroundColor(.primary)
+ BackgroundColor(Color.clear)
+ FontSize(fontSize)
+ }
+ .code {
+ FontFamilyVariant(.monospaced)
+ FontSize(.em(0.85))
+ BackgroundColor(Color.secondary.opacity(0.2))
+ }
+ .codeBlock { configuration in
+ let wrapCode = UserDefaults.shared.value(for: \.wrapCodeInChatCodeBlock)
+
+ if wrapCode {
+ configuration.label
+ .codeBlockLabelStyle()
+ .codeBlockStyle(
+ configuration,
+ backgroundColor: Color(nsColor: .textBackgroundColor).opacity(0.7),
+ labelColor: Color.secondary.opacity(0.7)
+ )
+ } else {
+ ScrollView(.horizontal) {
+ configuration.label
+ .codeBlockLabelStyle()
+ }
+ .workaroundForVerticalScrollingBugInMacOS()
+ .codeBlockStyle(
+ configuration,
+ backgroundColor: Color(nsColor: .textBackgroundColor).opacity(0.7),
+ labelColor: Color.secondary.opacity(0.7)
+ )
+ }
+ }
+ .table { configuration in
+ configuration.label
+ .fixedSize(horizontal: false, vertical: true)
+ .markdownTableBorderStyle(.init(
+ color: .init(nsColor: .separatorColor),
+ strokeStyle: .init(lineWidth: 1)
+ ))
+ .markdownTableBackgroundStyle(
+ .alternatingRows(Color.secondary.opacity(0.1), Color.secondary.opacity(0.2))
+ )
+ .markdownMargin(top: 0, bottom: 16)
+ }
+ .tableCell { configuration in
+ configuration.label
+ .markdownTextStyle {
+ if configuration.row == 0 {
+ FontWeight(.semibold)
+ }
+ BackgroundColor(nil)
+ }
+ .fixedSize(horizontal: false, vertical: true)
+ .padding(.vertical, 6)
+ .padding(.horizontal, 13)
+ .relativeLineSpacing(.em(0.25))
+ }
+ }
+}
+
diff --git a/Core/Sources/ChatGPTChatTab/Views/Instructions.swift b/Core/Sources/ChatGPTChatTab/Views/Instructions.swift
index 35097d08..dba6bfbf 100644
--- a/Core/Sources/ChatGPTChatTab/Views/Instructions.swift
+++ b/Core/Sources/ChatGPTChatTab/Views/Instructions.swift
@@ -7,28 +7,26 @@ struct Instruction: View {
let chat: StoreOf
var body: some View {
- Group {
- Markdown(
+ WithPerceptionTracking {
+ Group {
+ Markdown(
"""
You can use plugins to perform various tasks.
-
+
| 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`.
"""
- )
- .modifier(InstructionModifier())
-
- Markdown(
+ )
+ .modifier(InstructionModifier())
+
+ Markdown(
"""
You can use scopes to give the bot extra abilities.
-
+
| Scope Name | Abilities |
| --- | --- |
| `@file` | Read the metadata of the editing file |
@@ -36,29 +34,29 @@ struct Instruction: View {
| `@sense`| Experimental. Read the relevant code of the focused editor |
| `@project` | Experimental. Access content of the project |
| `@web` (beta) | Search on Bing or query from a web page |
-
+
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`.
"""
- )
- .modifier(InstructionModifier())
-
- WithViewStore(chat, observe: \.chatMenu.defaultScopes) { viewStore in
+ )
+ .modifier(InstructionModifier())
+
+ let scopes = chat.chatMenu.defaultScopes
Markdown(
- """
- Hello, I am your AI programming assistant. I can identify issues, explain and even improve code.
-
- \({
- if viewStore.state.isEmpty {
- return "No scope is enabled by default"
- } else {
- let scopes = viewStore.state.map(\.rawValue).sorted()
- .joined(separator: ", ")
- return "Default scopes: `\(scopes)`"
- }
- }())
- """
+ """
+ Hello, I am your AI programming assistant. I can identify issues, explain and even improve code.
+
+ \({
+ if scopes.isEmpty {
+ return "No scope is enabled by default"
+ } else {
+ let scopes = scopes.map(\.rawValue).sorted()
+ .joined(separator: ", ")
+ return "Default scopes: `\(scopes)`"
+ }
+ }())
+ """
)
.modifier(InstructionModifier())
}
diff --git a/Core/Sources/ChatGPTChatTab/Views/ThemedMarkdownText.swift b/Core/Sources/ChatGPTChatTab/Views/ThemedMarkdownText.swift
new file mode 100644
index 00000000..2811e4ad
--- /dev/null
+++ b/Core/Sources/ChatGPTChatTab/Views/ThemedMarkdownText.swift
@@ -0,0 +1,111 @@
+import Foundation
+import MarkdownUI
+import SwiftUI
+
+struct ThemedMarkdownText: View {
+ @AppStorage(\.syncChatCodeHighlightTheme) var syncCodeHighlightTheme
+ @AppStorage(\.codeForegroundColorLight) var codeForegroundColorLight
+ @AppStorage(\.codeBackgroundColorLight) var codeBackgroundColorLight
+ @AppStorage(\.codeForegroundColorDark) var codeForegroundColorDark
+ @AppStorage(\.codeBackgroundColorDark) var codeBackgroundColorDark
+ @AppStorage(\.chatFontSize) var chatFontSize
+ @AppStorage(\.chatCodeFont) var chatCodeFont
+ @Environment(\.colorScheme) var colorScheme
+
+ let content: MarkdownContent
+
+ init(_ text: String) {
+ content = .init(text)
+ }
+
+ init(_ content: MarkdownContent) {
+ self.content = content
+ }
+
+ var body: some View {
+ Markdown(content)
+ .textSelection(.enabled)
+ .markdownTheme(.custom(
+ fontSize: chatFontSize,
+ codeFont: chatCodeFont.value.nsFont,
+ codeBlockBackgroundColor: {
+ if syncCodeHighlightTheme {
+ if colorScheme == .light, let color = codeBackgroundColorLight.value {
+ return color.swiftUIColor
+ } else if let color = codeBackgroundColorDark.value {
+ return color.swiftUIColor
+ }
+ }
+
+ return Color(nsColor: .textBackgroundColor).opacity(0.7)
+ }(),
+ codeBlockLabelColor: {
+ if syncCodeHighlightTheme {
+ if colorScheme == .light,
+ let color = codeForegroundColorLight.value
+ {
+ return color.swiftUIColor.opacity(0.5)
+ } else if let color = codeForegroundColorDark.value {
+ return color.swiftUIColor.opacity(0.5)
+ }
+ }
+ return Color.secondary.opacity(0.7)
+ }()
+ ))
+ }
+}
+
+// MARK: - Theme
+
+extension MarkdownUI.Theme {
+ static func custom(
+ fontSize: Double,
+ codeFont: NSFont,
+ codeBlockBackgroundColor: Color,
+ codeBlockLabelColor: Color
+ ) -> MarkdownUI.Theme {
+ .gitHub.text {
+ ForegroundColor(.primary)
+ BackgroundColor(Color.clear)
+ FontSize(fontSize)
+ }
+ .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(
+ fenceInfo: configuration.language,
+ content: configuration.content,
+ font: codeFont
+ )
+ .codeBlockLabelStyle()
+ .codeBlockStyle(
+ configuration,
+ backgroundColor: codeBlockBackgroundColor,
+ labelColor: codeBlockLabelColor
+ )
+ } else {
+ ScrollView(.horizontal) {
+ AsyncCodeBlockView(
+ fenceInfo: configuration.language,
+ content: configuration.content,
+ font: codeFont
+ )
+ .codeBlockLabelStyle()
+ }
+ .workaroundForVerticalScrollingBugInMacOS()
+ .codeBlockStyle(
+ configuration,
+ backgroundColor: codeBlockBackgroundColor,
+ labelColor: codeBlockLabelColor
+ )
+ }
+ }
+ }
+}
+
diff --git a/Core/Sources/ChatGPTChatTab/Views/UserMessage.swift b/Core/Sources/ChatGPTChatTab/Views/UserMessage.swift
index f27e3ed4..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,27 +51,30 @@ 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: [], isReceivingMessage: false),
- reducer: Chat(service: .init())
+ initialState: .init(history: [] as [DisplayedChatMessage], isReceivingMessage: false),
+ reducer: { Chat(service: .init()) }
)
)
.padding()
- .fixedSize(horizontal: /*@START_MENU_TOKEN@*/true/*@END_MENU_TOKEN@*/, vertical: /*@START_MENU_TOKEN@*/true/*@END_MENU_TOKEN@*/)
+ .fixedSize(horizontal: true, vertical: true)
}
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/Client/AsyncXPCService.swift b/Core/Sources/Client/AsyncXPCService.swift
deleted file mode 100644
index 36729d5f..00000000
--- a/Core/Sources/Client/AsyncXPCService.swift
+++ /dev/null
@@ -1,248 +0,0 @@
-import Foundation
-import GitHubCopilotService
-import Logger
-import SuggestionModel
-import XPCShared
-
-public struct AsyncXPCService {
- public var connection: NSXPCConnection { service.connection }
- let service: XPCService
-
- init(service: XPCService) {
- self.service = service
- }
-
- public func getXPCServiceVersion() async throws -> (version: String, build: String) {
- try await withXPCServiceConnected(connection: connection) {
- service, continuation in
- service.getXPCServiceVersion { version, build in
- continuation.resume((version, build))
- }
- }
- }
-
- public func getXPCServiceAccessibilityPermission() async throws -> Bool {
- try await withXPCServiceConnected(connection: connection) {
- service, continuation in
- service.getXPCServiceAccessibilityPermission { isGranted in
- continuation.resume(isGranted)
- }
- }
- }
-
- public func getSuggestedCode(editorContent: EditorContent) async throws -> UpdatedContent? {
- try await suggestionRequest(
- connection,
- editorContent,
- { $0.getSuggestedCode }
- )
- }
-
- public func getNextSuggestedCode(editorContent: EditorContent) async throws -> UpdatedContent? {
- try await suggestionRequest(
- connection,
- editorContent,
- { $0.getNextSuggestedCode }
- )
- }
-
- public func getPreviousSuggestedCode(editorContent: EditorContent) async throws
- -> UpdatedContent?
- {
- try await suggestionRequest(
- connection,
- editorContent,
- { $0.getPreviousSuggestedCode }
- )
- }
-
- public func getSuggestionAcceptedCode(editorContent: EditorContent) async throws
- -> UpdatedContent?
- {
- try await suggestionRequest(
- connection,
- editorContent,
- { $0.getSuggestionAcceptedCode }
- )
- }
-
- public func getSuggestionRejectedCode(editorContent: EditorContent) async throws
- -> UpdatedContent?
- {
- try await suggestionRequest(
- connection,
- editorContent,
- { $0.getSuggestionRejectedCode }
- )
- }
-
- public func getRealtimeSuggestedCode(editorContent: EditorContent) async throws
- -> UpdatedContent?
- {
- try await suggestionRequest(
- connection,
- editorContent,
- { $0.getRealtimeSuggestedCode }
- )
- }
-
- public func getPromptToCodeAcceptedCode(editorContent: EditorContent) async throws
- -> UpdatedContent?
- {
- try await suggestionRequest(
- connection,
- editorContent,
- { $0.getPromptToCodeAcceptedCode }
- )
- }
-
- public func toggleRealtimeSuggestion() async throws {
- try await withXPCServiceConnected(connection: connection) {
- service, continuation in
- service.toggleRealtimeSuggestion { error in
- if let error {
- continuation.reject(error)
- return
- }
- continuation.resume(())
- }
- } as Void
- }
-
- public func prefetchRealtimeSuggestions(editorContent: EditorContent) async {
- guard let data = try? JSONEncoder().encode(editorContent) else { return }
- try? await withXPCServiceConnected(connection: connection) { service, continuation in
- service.prefetchRealtimeSuggestions(editorContent: data) {
- continuation.resume(())
- }
- }
- }
-
- public func chatWithSelection(editorContent: EditorContent) async throws -> UpdatedContent? {
- try await suggestionRequest(
- connection,
- editorContent,
- { $0.chatWithSelection }
- )
- }
-
- public func promptToCode(editorContent: EditorContent) async throws -> UpdatedContent? {
- try await suggestionRequest(
- connection,
- editorContent,
- { $0.promptToCode }
- )
- }
-
- public func customCommand(
- id: String,
- editorContent: EditorContent
- ) async throws -> UpdatedContent? {
- try await suggestionRequest(
- connection,
- editorContent,
- { service in { service.customCommand(id: id, editorContent: $0, withReply: $1) } }
- )
- }
-
- public func postNotification(name: String) async throws {
- try await withXPCServiceConnected(connection: connection) {
- service, continuation in
- service.postNotification(name: name) {
- continuation.resume(())
- }
- }
- }
-
- public func send(
- requestBody: M
- ) async throws -> M.ResponseBody {
- try await withXPCServiceConnected(connection: connection) { service, continuation in
- do {
- let requestBodyData = try JSONEncoder().encode(requestBody)
- service.send(endpoint: M.endpoint, requestBody: requestBodyData) { data, error in
- if let error {
- continuation.reject(error)
- } else {
- do {
- guard let data = data else {
- continuation.reject(NoDataError())
- return
- }
- let responseBody = try JSONDecoder().decode(
- M.ResponseBody.self,
- from: data
- )
- continuation.resume(responseBody)
- } catch {
- continuation.reject(error)
- }
- }
- }
- } catch {
- continuation.reject(error)
- }
- }
- }
-}
-
-struct NoDataError: Error {}
-
-struct AutoFinishContinuation {
- var continuation: AsyncThrowingStream.Continuation
-
- func resume(_ value: T) {
- continuation.yield(value)
- continuation.finish()
- }
-
- func reject(_ error: Error) {
- if (error as NSError).code == -100 {
- continuation.finish(throwing: CancellationError())
- } else {
- continuation.finish(throwing: error)
- }
- }
-}
-
-func withXPCServiceConnected(
- connection: NSXPCConnection,
- _ fn: @escaping (XPCServiceProtocol, AutoFinishContinuation) -> Void
-) async throws -> T {
- let stream: AsyncThrowingStream = AsyncThrowingStream { continuation in
- let service = connection.remoteObjectProxyWithErrorHandler {
- continuation.finish(throwing: $0)
- } as! XPCServiceProtocol
- fn(service, .init(continuation: continuation))
- }
- return try await stream.first(where: { _ in true })!
-}
-
-func suggestionRequest(
- _ connection: NSXPCConnection,
- _ editorContent: EditorContent,
- _ fn: @escaping (any XPCServiceProtocol) -> (Data, @escaping (Data?, Error?) -> Void) -> Void
-) async throws -> UpdatedContent? {
- let data = try JSONEncoder().encode(editorContent)
- return try await withXPCServiceConnected(connection: connection) {
- service, continuation in
- fn(service)(data) { updatedData, error in
- if let error {
- continuation.reject(error)
- return
- }
- do {
- if let updatedData {
- let updatedContent = try JSONDecoder()
- .decode(UpdatedContent.self, from: updatedData)
- continuation.resume(updatedContent)
- } else {
- continuation.resume(nil)
- }
- } catch {
- continuation.reject(error)
- }
- }
- }
-}
-
diff --git a/Core/Sources/Client/XPCService.swift b/Core/Sources/Client/XPCService.swift
index 99cebfc7..24a50bab 100644
--- a/Core/Sources/Client/XPCService.swift
+++ b/Core/Sources/Client/XPCService.swift
@@ -3,58 +3,12 @@ import Logger
import os.log
import XPCShared
-let shared = XPCService()
+let shared = XPCExtensionService(logger: .client)
-public func getService() throws -> AsyncXPCService {
+public func getService() throws -> XPCExtensionService {
if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" {
struct RunningInPreview: Error {}
throw RunningInPreview()
}
- return AsyncXPCService(service: shared)
-}
-
-class XPCService {
- private var isInvalidated = false
- private lazy var _connection: NSXPCConnection = buildConnection()
-
- var connection: NSXPCConnection {
- if isInvalidated {
- _connection.invalidationHandler = {}
- _connection.interruptionHandler = {}
- isInvalidated = false
- _connection.invalidate()
- rebuildConnection()
- }
- return _connection
- }
-
- private func buildConnection() -> NSXPCConnection {
- let connection = NSXPCConnection(
- machServiceName: Bundle(for: XPCService.self)
- .object(forInfoDictionaryKey: "BUNDLE_IDENTIFIER_BASE") as! String +
- ".ExtensionService"
- )
- connection.remoteObjectInterface =
- NSXPCInterface(with: XPCServiceProtocol.self)
- connection.invalidationHandler = { [weak self] in
- Logger.client.info("XPCService Invalidated")
- self?.isInvalidated = true
- }
- connection.interruptionHandler = { [weak self] in
- Logger.client.info("XPCService interrupted")
- self?.isInvalidated = true
- }
- connection.resume()
- return connection
- }
-
- func rebuildConnection() {
- _connection = buildConnection()
- }
-
- deinit {
- _connection.invalidationHandler = {}
- _connection.interruptionHandler = {}
- _connection.invalidate()
- }
+ return shared
}
diff --git a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManagementView.swift b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManagementView.swift
index 48bd8632..bc6c910e 100644
--- a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManagementView.swift
+++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManagementView.swift
@@ -3,51 +3,53 @@ import SharedUIComponents
import SwiftUI
struct APIKeyManagementView: View {
- let store: StoreOf
+ @Perception.Bindable var store: StoreOf
var body: some View {
- VStack(spacing: 0) {
- HStack {
- Button(action: {
- store.send(.closeButtonClicked)
- }) {
- Image(systemName: "xmark.circle.fill")
- .foregroundStyle(.secondary)
- .padding()
- }
- .buttonStyle(.plain)
- Text("API Keys")
- Spacer()
- Button(action: {
- store.send(.addButtonClicked)
- }) {
- Image(systemName: "plus.circle.fill")
- .foregroundStyle(.secondary)
- .padding()
+ WithPerceptionTracking {
+ VStack(spacing: 0) {
+ HStack {
+ Button(action: {
+ store.send(.closeButtonClicked)
+ }) {
+ Image(systemName: "xmark.circle.fill")
+ .foregroundStyle(.secondary)
+ .padding()
+ }
+ .buttonStyle(.plain)
+ Text("API Keys")
+ Spacer()
+ Button(action: {
+ store.send(.addButtonClicked)
+ }) {
+ Image(systemName: "plus.circle.fill")
+ .foregroundStyle(.secondary)
+ .padding()
+ }
+ .buttonStyle(.plain)
}
- .buttonStyle(.plain)
- }
- .background(Color(nsColor: .separatorColor))
+ .background(Color(nsColor: .separatorColor))
- List {
- WithViewStore(store, observe: { $0.availableAPIKeyNames }) { viewStore in
- ForEach(viewStore.state, id: \.self) { name in
- HStack {
- Text(name)
- .contextMenu {
- Button("Remove") {
- viewStore.send(.deleteButtonClicked(name: name))
+ List {
+ ForEach(store.availableAPIKeyNames, id: \.self) { name in
+ WithPerceptionTracking {
+ HStack {
+ Text(name)
+ .contextMenu {
+ Button("Remove") {
+ store.send(.deleteButtonClicked(name: name))
+ }
}
- }
- Spacer()
+ Spacer()
- Button(action: {
- viewStore.send(.deleteButtonClicked(name: name))
- }) {
- Image(systemName: "trash.fill")
- .foregroundStyle(.secondary)
+ Button(action: {
+ store.send(.deleteButtonClicked(name: name))
+ }) {
+ Image(systemName: "trash.fill")
+ .foregroundStyle(.secondary)
+ }
+ .buttonStyle(.plain)
}
- .buttonStyle(.plain)
}
}
.modify { view in
@@ -58,11 +60,9 @@ struct APIKeyManagementView: View {
}
}
}
- }
- .removeBackground()
- .overlay {
- WithViewStore(store, observe: { $0.availableAPIKeyNames }) { viewStore in
- if viewStore.state.isEmpty {
+ .removeBackground()
+ .overlay {
+ if store.availableAPIKeyNames.isEmpty {
Text("""
Empty
Add a new key by clicking the add button
@@ -72,52 +72,53 @@ struct APIKeyManagementView: View {
}
}
}
- }
- .focusable(false)
- .frame(width: 300, height: 400)
- .background(.thickMaterial)
- .onAppear {
- store.send(.appear)
- }
- .sheet(store: store.scope(
- state: \.$apiKeySubmission,
- action: APIKeyManagement.Action.apiKeySubmission
- )) { store in
- APIKeySubmissionView(store: store)
- .frame(minWidth: 400)
+ .focusable(false)
+ .frame(width: 300, height: 400)
+ .background(.thickMaterial)
+ .onAppear {
+ store.send(.appear)
+ }
+ .sheet(item: $store.scope(
+ state: \.apiKeySubmission,
+ action: \.apiKeySubmission
+ )) { store in
+ WithPerceptionTracking {
+ APIKeySubmissionView(store: store)
+ .frame(minWidth: 400)
+ }
+ }
}
}
}
struct APIKeySubmissionView: View {
- let store: StoreOf
+ @Perception.Bindable var store: StoreOf
var body: some View {
- ScrollView {
- VStack(spacing: 0) {
- Form {
- WithViewStore(store, removeDuplicates: { $0.name == $1.name }) { viewStore in
- TextField("Name", text: viewStore.$name)
- }
- WithViewStore(store, removeDuplicates: { $0.key == $1.key }) { viewStore in
- SecureField("Key", text: viewStore.$key)
+ WithPerceptionTracking {
+ ScrollView {
+ VStack(spacing: 0) {
+ Form {
+ TextField("Name", text: $store.name)
+ SecureField("Key", text: $store.key)
}
- }.padding()
+ .padding()
- Divider()
+ Divider()
- HStack {
- Spacer()
+ HStack {
+ Spacer()
- Button("Cancel") { store.send(.cancelButtonClicked) }
- .keyboardShortcut(.cancelAction)
+ Button("Cancel") { store.send(.cancelButtonClicked) }
+ .keyboardShortcut(.cancelAction)
- Button("Save", action: { store.send(.saveButtonClicked) })
- .keyboardShortcut(.defaultAction)
- }.padding()
+ Button("Save", action: { store.send(.saveButtonClicked) })
+ .keyboardShortcut(.defaultAction)
+ }.padding()
+ }
}
+ .textFieldStyle(.roundedBorder)
}
- .textFieldStyle(.roundedBorder)
}
}
@@ -128,7 +129,7 @@ class APIKeyManagementView_Preview: PreviewProvider {
initialState: .init(
availableAPIKeyNames: ["test1", "test2"]
),
- reducer: APIKeyManagement()
+ reducer: { APIKeyManagement() }
)
)
}
@@ -139,7 +140,7 @@ class APIKeySubmissionView_Preview: PreviewProvider {
APIKeySubmissionView(
store: .init(
initialState: .init(),
- reducer: APIKeySubmission()
+ reducer: { APIKeySubmission() }
)
)
}
diff --git a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManangement.swift b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManangement.swift
index 3ff3188e..2756ce1e 100644
--- a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManangement.swift
+++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManangement.swift
@@ -1,10 +1,12 @@
import ComposableArchitecture
import Foundation
-struct APIKeyManagement: ReducerProtocol {
+@Reducer
+struct APIKeyManagement {
+ @ObservableState
struct State: Equatable {
var availableAPIKeyNames: [String] = []
- @PresentationState var apiKeySubmission: APIKeySubmission.State?
+ @Presents var apiKeySubmission: APIKeySubmission.State?
}
enum Action: Equatable {
@@ -20,7 +22,7 @@ struct APIKeyManagement: ReducerProtocol {
@Dependency(\.toast) var toast
@Dependency(\.apiKeyKeychain) var keychain
- var body: some ReducerProtocol {
+ var body: some ReducerOf {
Reduce { state, action in
switch action {
case .appear:
@@ -72,7 +74,7 @@ struct APIKeyManagement: ReducerProtocol {
return .none
}
}
- .ifLet(\.$apiKeySubmission, action: /Action.apiKeySubmission) {
+ .ifLet(\.$apiKeySubmission, action: \.apiKeySubmission) {
APIKeySubmission()
}
}
diff --git a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyPicker.swift b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyPicker.swift
index a18e0a4c..57e853d4 100644
--- a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyPicker.swift
+++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyPicker.swift
@@ -2,26 +2,27 @@ import ComposableArchitecture
import SwiftUI
struct APIKeyPicker: View {
- let store: StoreOf
+ @Perception.Bindable var store: StoreOf
var body: some View {
- WithViewStore(store) { viewStore in
+ WithPerceptionTracking {
HStack {
Picker(
- selection: viewStore.$apiKeyName,
+ selection: $store.apiKeyName,
content: {
Text("No API Key").tag("")
- if viewStore.state.availableAPIKeyNames.isEmpty {
+ if store.availableAPIKeyNames.isEmpty {
Text("No API key found, please add a new one →")
}
-
- if !viewStore.state.availableAPIKeyNames.contains(viewStore.state.apiKeyName),
- !viewStore.state.apiKeyName.isEmpty {
- Text("Key not found: \(viewStore.state.apiKeyName)")
- .tag(viewStore.state.apiKeyName)
+
+ if !store.availableAPIKeyNames.contains(store.apiKeyName),
+ !store.apiKeyName.isEmpty
+ {
+ Text("Key not found: \(store.apiKeyName)")
+ .tag(store.apiKeyName)
}
-
- ForEach(viewStore.state.availableAPIKeyNames, id: \.self) { name in
+
+ ForEach(store.availableAPIKeyNames, id: \.self) { name in
Text(name).tag(name)
}
@@ -32,15 +33,17 @@ struct APIKeyPicker: View {
Button(action: { store.send(.manageAPIKeysButtonClicked) }) {
Text(Image(systemName: "key"))
}
- }.sheet(isPresented: viewStore.$isAPIKeyManagementPresented) {
- APIKeyManagementView(store: store.scope(
- state: \.apiKeyManagement,
- action: APIKeySelection.Action.apiKeyManagement
- ))
+ }.sheet(isPresented: $store.isAPIKeyManagementPresented) {
+ WithPerceptionTracking {
+ APIKeyManagementView(store: store.scope(
+ state: \.apiKeyManagement,
+ action: \.apiKeyManagement
+ ))
+ }
+ }
+ .onAppear {
+ store.send(.appear)
}
- }
- .onAppear {
- store.send(.appear)
}
}
}
diff --git a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySelection.swift b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySelection.swift
index 75e2d77c..47e8b33b 100644
--- a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySelection.swift
+++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySelection.swift
@@ -2,14 +2,16 @@ import Foundation
import SwiftUI
import ComposableArchitecture
-struct APIKeySelection: ReducerProtocol {
+@Reducer
+struct APIKeySelection {
+ @ObservableState
struct State: Equatable {
- @BindingState var apiKeyName: String = ""
+ var apiKeyName: String = ""
var availableAPIKeyNames: [String] {
apiKeyManagement.availableAPIKeyNames
}
var apiKeyManagement: APIKeyManagement.State = .init()
- @BindingState var isAPIKeyManagementPresented: Bool = false
+ var isAPIKeyManagementPresented: Bool = false
}
enum Action: Equatable, BindableAction {
@@ -23,10 +25,10 @@ struct APIKeySelection: ReducerProtocol {
@Dependency(\.toast) var toast
@Dependency(\.apiKeyKeychain) var keychain
- var body: some ReducerProtocol {
+ var body: some ReducerOf {
BindingReducer()
- Scope(state: \.apiKeyManagement, action: /Action.apiKeyManagement) {
+ Scope(state: \.apiKeyManagement, action: \.apiKeyManagement) {
APIKeyManagement()
}
diff --git a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySubmission.swift b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySubmission.swift
index 64f16b7d..8fe390ee 100644
--- a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySubmission.swift
+++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySubmission.swift
@@ -1,10 +1,12 @@
import ComposableArchitecture
import Foundation
-struct APIKeySubmission: ReducerProtocol {
+@Reducer
+struct APIKeySubmission {
+ @ObservableState
struct State: Equatable {
- @BindingState var name: String = ""
- @BindingState var key: String = ""
+ var name: String = ""
+ var key: String = ""
}
enum Action: Equatable, BindableAction {
@@ -22,7 +24,7 @@ struct APIKeySubmission: ReducerProtocol {
case keyIsEmpty
}
- var body: some ReducerProtocol {
+ var body: some ReducerOf {
BindingReducer()
Reduce { state, action in
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 62eec368..f0c673e5 100644
--- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift
+++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift
@@ -7,16 +7,18 @@ import Preferences
import SwiftUI
import Toast
-struct ChatModelEdit: ReducerProtocol {
+@Reducer
+struct ChatModelEdit {
+ @ObservableState
struct State: Equatable, Identifiable {
var id: String
- @BindingState var name: String
- @BindingState var format: ChatModel.Format
- @BindingState var maxTokens: Int = 4000
- @BindingState var supportsFunctionCalling: Bool = true
- @BindingState var modelName: String = ""
- @BindingState var ollamaKeepAlive: String = ""
- @BindingState var apiVersion: String = ""
+ var name: String
+ var format: ChatModel.Format
+ var maxTokens: Int = 4000
+ var supportsFunctionCalling: Bool = true
+ var modelName: String = ""
+ var ollamaKeepAlive: String = ""
+ var apiVersion: String = ""
var apiKeyName: String { apiKeySelection.apiKeyName }
var baseURL: String { baseURLSelection.baseURL }
var isFullURL: Bool { baseURLSelection.isFullURL }
@@ -26,6 +28,14 @@ struct ChatModelEdit: ReducerProtocol {
var suggestedMaxTokens: Int?
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 {
@@ -38,10 +48,44 @@ struct ChatModelEdit: ReducerProtocol {
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 {
@@ -51,14 +95,14 @@ struct ChatModelEdit: ReducerProtocol {
@Dependency(\.apiKeyKeychain) var keychain
- var body: some ReducerProtocol {
+ var body: some ReducerOf {
BindingReducer()
- Scope(state: \.apiKeySelection, action: /Action.apiKeySelection) {
+ Scope(state: \.apiKeySelection, action: \.apiKeySelection) {
APIKeySelection()
}
- Scope(state: \.baseURLSelection, action: /Action.baseURLSelection) {
+ Scope(state: \.baseURLSelection, action: \.baseURLSelection) {
BaseURLSelection()
}
@@ -82,21 +126,33 @@ struct ChatModelEdit: ReducerProtocol {
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))
@@ -145,24 +201,66 @@ struct ChatModelEdit: ReducerProtocol {
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
case .baseURLSelection:
return .none
- case .binding(\.$format):
+ case .binding(\.format):
return .run { send in
await send(.refreshAvailableModelNames)
await send(.checkSuggestedMaxTokens)
}
- case .binding(\.$modelName):
+ case .binding(\.modelName):
return .run { send in
await send(.checkSuggestedMaxTokens)
}
@@ -174,26 +272,6 @@ struct ChatModelEdit: ReducerProtocol {
}
}
-extension ChatModelEdit.State {
- init(model: ChatModel) {
- self.init(
- id: model.id,
- name: model.name,
- format: model.format,
- maxTokens: model.info.maxTokens,
- supportsFunctionCalling: model.info.supportsFunctionCalling,
- modelName: model.info.modelName,
- ollamaKeepAlive: model.info.ollamaInfo.keepAlive,
- apiVersion: model.info.googleGenerativeAIInfo.apiVersion,
- apiKeySelection: .init(
- apiKeyName: model.info.apiKeyName,
- apiKeyManagement: .init(availableAPIKeyNames: [model.info.apiKeyName])
- ),
- baseURLSelection: .init(baseURL: model.info.baseURL, isFullURL: model.info.isFullURL)
- )
- }
-}
-
extension ChatModel {
init(state: ChatModelEdit.State) {
self.init(
@@ -209,15 +287,56 @@ 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)
+ googleGenerativeAIInfo: .init(apiVersion: state.apiVersion),
+ openAICompatibleInfo: .init(
+ enforceMessageOrder: state.enforceMessageOrder,
+ supportsMultipartMessageContent: state
+ .openAICompatibleSupportsMultipartMessageContent,
+ requiresBeginWithUserMessage: state.requiresBeginWithUserMessage
+ ),
+ customHeaderInfo: .init(headers: state.customHeaders),
+ customBodyInfo: .init(jsonBody: state.customBody)
)
)
}
+
+ func toState() -> ChatModelEdit.State {
+ .init(
+ id: id,
+ name: name,
+ format: format,
+ maxTokens: info.maxTokens,
+ supportsFunctionCalling: info.supportsFunctionCalling,
+ modelName: info.modelName,
+ ollamaKeepAlive: info.ollamaInfo.keepAlive,
+ apiVersion: info.googleGenerativeAIInfo.apiVersion,
+ apiKeySelection: .init(
+ apiKeyName: info.apiKeyName,
+ apiKeyManagement: .init(availableAPIKeyNames: [info.apiKeyName])
+ ),
+ baseURLSelection: .init(baseURL: info.baseURL, isFullURL: info.isFullURL),
+ 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 2fe21715..d16b7556 100644
--- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift
+++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift
@@ -6,445 +6,621 @@ import SwiftUI
@MainActor
struct ChatModelEditView: View {
- let store: StoreOf
+ @Perception.Bindable var store: StoreOf
var body: some View {
- ScrollView {
- VStack(spacing: 0) {
- Form {
- nameTextField
- formatPicker
-
- WithViewStore(store, observe: { $0.format }) { viewStore in
- switch viewStore.state {
+ WithPerceptionTracking {
+ ScrollView {
+ VStack(spacing: 0) {
+ Form {
+ NameTextField(store: store)
+ FormatPicker(store: store)
+
+ switch store.format {
case .openAI:
- openAI
+ OpenAIForm(store: store)
case .azureOpenAI:
- azureOpenAI
+ AzureOpenAIForm(store: store)
case .openAICompatible:
- openAICompatible
+ OpenAICompatibleForm(store: store)
case .googleAI:
- googleAI
+ GoogleAIForm(store: store)
case .ollama:
- ollama
+ OllamaForm(store: store)
case .claude:
- claude
+ ClaudeForm(store: store)
+ case .gitHubCopilot:
+ GitHubCopilotForm(store: store)
}
}
- }
- .padding()
+ .padding()
- Divider()
+ Divider()
- HStack {
- WithViewStore(store, observe: { $0.isTesting }) { viewStore in
+ HStack {
HStack(spacing: 8) {
Button("Test") {
store.send(.testButtonClicked)
}
- .disabled(viewStore.state)
+ .disabled(store.isTesting)
- if viewStore.state {
+ if store.isTesting {
ProgressView()
.controlSize(.small)
}
}
- }
- Spacer()
-
- Button("Cancel") {
- store.send(.cancelButtonClicked)
- }
- .keyboardShortcut(.cancelAction)
+ 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") {
+ store.send(.cancelButtonClicked)
+ }
+ .keyboardShortcut(.cancelAction)
- Button(action: { store.send(.saveButtonClicked) }) {
- Text("Save")
+ Button(action: { store.send(.saveButtonClicked) }) {
+ Text("Save")
+ }
+ .keyboardShortcut(.defaultAction)
}
- .keyboardShortcut(.defaultAction)
+ .padding()
}
- .padding()
}
+ .textFieldStyle(.roundedBorder)
+ .onAppear {
+ store.send(.appear)
+ }
+ .fixedSize(horizontal: false, vertical: true)
+ .handleToast(namespace: "ChatModelEdit")
}
- .textFieldStyle(.roundedBorder)
- .onAppear {
- store.send(.appear)
- }
- .fixedSize(horizontal: false, vertical: true)
- .handleToast(namespace: "ChatModelEdit")
}
- var nameTextField: some View {
- WithViewStore(store, removeDuplicates: { $0.name == $1.name }) { viewStore in
- TextField("Name", text: viewStore.$name)
+ struct NameTextField: View {
+ @Perception.Bindable var store: StoreOf
+ var body: some View {
+ WithPerceptionTracking {
+ TextField("Name", text: $store.name)
+ }
}
}
- var formatPicker: some View {
- WithViewStore(store, removeDuplicates: { $0.format == $1.format }) { viewStore in
- Picker(
- selection: viewStore.$format,
- content: {
- ForEach(
- ChatModel.Format.allCases,
- id: \.rawValue
- ) { format in
- switch format {
- case .openAI:
- Text("OpenAI").tag(format)
- case .azureOpenAI:
- Text("Azure OpenAI").tag(format)
- case .openAICompatible:
- Text("OpenAI Compatible").tag(format)
- case .googleAI:
- Text("Google Generative AI").tag(format)
- case .ollama:
- Text("Ollama").tag(format)
- case .claude:
- Text("Claude").tag(format)
+ struct FormatPicker: View {
+ @Perception.Bindable var store: StoreOf
+ var body: some View {
+ WithPerceptionTracking {
+ Picker(
+ selection: Binding(
+ get: { .init(store.format) },
+ set: { store.send(.selectModelFormat($0)) }
+ ),
+ content: {
+ ForEach(
+ ChatModelEdit.ModelFormat.allCases,
+ id: \.self
+ ) { format in
+ switch format {
+ case .openAI:
+ Text("OpenAI")
+ case .azureOpenAI:
+ Text("Azure OpenAI")
+ case .openAICompatible:
+ Text("OpenAI Compatible")
+ case .googleAI:
+ Text("Google AI")
+ case .ollama:
+ Text("Ollama")
+ case .claude:
+ 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)
+ },
+ label: { Text("Format") }
+ )
+ .pickerStyle(.menu)
+ }
}
}
- func baseURLTextField(
- title: String = "Base URL",
- prompt: Text?,
- @ViewBuilder trailingContent: @escaping () -> V
- ) -> some View {
- BaseURLPicker(
- title: title,
- prompt: prompt,
- store: store.scope(
- state: \.baseURLSelection,
- action: ChatModelEdit.Action.baseURLSelection
- ),
- trailingContent: trailingContent
- )
+ struct BaseURLTextField: View {
+ let store: StoreOf
+ var title: String = "Base URL"
+ let prompt: Text?
+ @ViewBuilder var trailingContent: () -> V
+
+ var body: some View {
+ WithPerceptionTracking {
+ BaseURLPicker(
+ title: title,
+ prompt: prompt,
+ store: store.scope(
+ state: \.baseURLSelection,
+ action: \.baseURLSelection
+ ),
+ trailingContent: trailingContent
+ )
+ }
+ }
}
- func baseURLTextField(
- title: String = "Base URL",
- prompt: Text?
- ) -> some View {
- baseURLTextField(title: title, prompt: prompt, trailingContent: { EmptyView() })
- }
+ struct SupportsFunctionCallingToggle: View {
+ @Perception.Bindable var store: StoreOf
+ var body: some View {
+ WithPerceptionTracking {
+ Toggle(
+ "Supports Function Calling",
+ isOn: $store.supportsFunctionCalling
+ )
- var supportsFunctionCallingToggle: some View {
- WithViewStore(
- store,
- removeDuplicates: { $0.supportsFunctionCalling == $1.supportsFunctionCalling }
- ) { viewStore in
- Toggle(
- "Supports Function Calling",
- isOn: viewStore.$supportsFunctionCalling
- )
-
- Text(
- "Function calling is required by some features, if this model doesn't support function calling, you should turn it off to avoid undefined behaviors."
- )
- .foregroundColor(.secondary)
- .font(.callout)
- .dynamicHeightTextInFormWorkaround()
+ Text(
+ "Function calling is required by some features, if this model doesn't support function calling, you should turn it off to avoid undefined behaviors."
+ )
+ .foregroundColor(.secondary)
+ .font(.callout)
+ .dynamicHeightTextInFormWorkaround()
+ }
}
}
- struct MaxTokensTextField: Equatable {
- @BindingViewState var maxTokens: Int
- var suggestedMaxTokens: Int?
- }
+ struct MaxTokensTextField: View {
+ @Perception.Bindable var store: StoreOf
- var maxTokensTextField: some View {
- WithViewStore(
- store,
- observe: {
- MaxTokensTextField(
- maxTokens: $0.$maxTokens,
- suggestedMaxTokens: $0.suggestedMaxTokens
- )
- }
- ) { viewStore in
- HStack {
- let textFieldBinding = Binding(
- get: { String(viewStore.state.maxTokens) },
- set: {
- if let selectionMaxToken = Int($0) {
- viewStore.$maxTokens.wrappedValue = selectionMaxToken
- } else {
- viewStore.$maxTokens.wrappedValue = 0
+ var body: some View {
+ WithPerceptionTracking {
+ HStack {
+ let textFieldBinding = Binding(
+ get: { String(store.maxTokens) },
+ set: {
+ if let selectionMaxToken = Int($0) {
+ $store.maxTokens.wrappedValue = selectionMaxToken
+ } else {
+ $store.maxTokens.wrappedValue = 0
+ }
}
- }
- )
+ )
- TextField(text: textFieldBinding) {
- Text("Context Window")
- .multilineTextAlignment(.trailing)
- }
- .overlay(alignment: .trailing) {
- Stepper(
- value: viewStore.$maxTokens,
- in: 0...Int.max,
- step: 100
- ) {
- EmptyView()
- }
- }
- .foregroundColor({
- guard let max = viewStore.state.suggestedMaxTokens else {
- return .primary
+ TextField(text: textFieldBinding) {
+ Text("Context Window")
+ .multilineTextAlignment(.trailing)
}
- if viewStore.state.maxTokens > max {
- return .red
+ .overlay(alignment: .trailing) {
+ Stepper(
+ value: $store.maxTokens,
+ in: 0...Int.max,
+ step: 100
+ ) {
+ EmptyView()
+ }
}
- return .primary
- }() as Color)
+ .foregroundColor({
+ guard let max = store.suggestedMaxTokens else {
+ return .primary
+ }
+ if store.maxTokens > max {
+ return .red
+ }
+ return .primary
+ }() as Color)
- if let max = viewStore.state.suggestedMaxTokens {
- Text("Max: \(max)")
+ if let max = store.suggestedMaxTokens {
+ Text("Max: \(max)")
+ }
}
}
}
}
- struct APIKeyState: Equatable {
- @BindingViewState var apiKeyName: String
- var availableAPIKeys: [String]
+ struct ApiKeyNamePicker: View {
+ let store: StoreOf
+ var body: some View {
+ WithPerceptionTracking {
+ APIKeyPicker(store: store.scope(
+ state: \.apiKeySelection,
+ action: \.apiKeySelection
+ ))
+ }
+ }
}
- @ViewBuilder
- var apiKeyNamePicker: some View {
- APIKeyPicker(store: store.scope(
- state: \.apiKeySelection,
- action: ChatModelEdit.Action.apiKeySelection
- ))
- }
+ struct CustomBodyEdit: View {
+ @Perception.Bindable var store: StoreOf
+ @State private var isEditing = false
+ @Dependency(\.namespacedToast) var toast
- @ViewBuilder
- var openAI: some View {
- baseURLTextField(prompt: Text("https://api.openai.com")) {
- Text("/v1/chat/completions")
- }
- apiKeyNamePicker
-
- WithViewStore(
- store,
- removeDuplicates: { $0.modelName == $1.modelName }
- ) { viewStore in
- TextField("Model Name", text: viewStore.$modelName)
- .overlay(alignment: .trailing) {
- Picker(
- "",
- selection: viewStore.$modelName,
- content: {
- if ChatGPTModel(rawValue: viewStore.state.modelName) == nil {
- Text("Custom Model").tag(viewStore.state.modelName)
+ 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
}
- ForEach(ChatGPTModel.allCases, id: \.self) { model in
- Text(model.rawValue).tag(model.rawValue)
+ guard let _ = try? JSONSerialization
+ .jsonObject(with: store.customBody.data(using: .utf8) ?? Data())
+ else {
+ toast("Invalid JSON object", .error, "CustomBodyEdit")
+ return
}
+ isEditing = false
+ }) {
+ Text("Done")
}
- )
- .frame(width: 20)
+ .keyboardShortcut(.defaultAction)
+ }
+ .padding()
+ .frame(width: 600, height: 500)
+ .background(Color(nsColor: .windowBackgroundColor))
}
+ }
}
+ }
- maxTokensTextField
- supportsFunctionCallingToggle
-
- 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)"
- )
+ struct CustomHeaderEdit: View {
+ @Perception.Bindable var store: StoreOf
+ @State private var isEditing = false
- Text(Image(systemName: "exclamationmark.triangle.fill")) + Text(
- " If you don't have access to GPT-4, you may need to visit [https://platform.openai.com/account/billing/overview](https://platform.openai.com/account/billing/overview) to buy some credits. A ChatGPT Plus subscription is not enough to access GPT-4 through API."
- )
+ var body: some View {
+ Button("Custom Headers") {
+ isEditing = true
+ }
+ .sheet(isPresented: $isEditing) {
+ WithPerceptionTracking {
+ CustomHeaderSettingsView(headers: $store.customHeaders)
+ }
+ }
}
- .padding(.vertical)
}
- @ViewBuilder
- var azureOpenAI: some View {
- baseURLTextField(prompt: Text("https://xxxx.openai.azure.com"))
- apiKeyNamePicker
+ struct OpenAIForm: View {
+ @Perception.Bindable var store: StoreOf
+ var body: some View {
+ WithPerceptionTracking {
+ BaseURLTextField(store: store, prompt: Text("https://api.openai.com")) {
+ Text("/v1/chat/completions")
+ }
+ ApiKeyNamePicker(store: store)
+
+ TextField("Model Name", text: $store.modelName)
+ .overlay(alignment: .trailing) {
+ Picker(
+ "",
+ selection: $store.modelName,
+ content: {
+ if ChatGPTModel(rawValue: store.modelName) == nil {
+ Text("Custom Model").tag(store.modelName)
+ }
+ ForEach(ChatGPTModel.allCases, id: \.self) { model in
+ Text(model.rawValue).tag(model.rawValue)
+ }
+ }
+ )
+ .frame(width: 20)
+ }
- WithViewStore(
- store,
- removeDuplicates: { $0.modelName == $1.modelName }
- ) { viewStore in
- TextField("Deployment Name", text: viewStore.$modelName)
- }
+ MaxTokensTextField(store: store)
+ SupportsFunctionCallingToggle(store: store)
- maxTokensTextField
- supportsFunctionCallingToggle
- }
+ TextField(text: $store.openAIOrganizationID, prompt: Text("Optional")) {
+ Text("Organization ID")
+ }
- @ViewBuilder
- var openAICompatible: some View {
- WithViewStore(store.scope(
- state: \.baseURLSelection,
- action: ChatModelEdit.Action.baseURLSelection
- ), removeDuplicates: { $0.isFullURL != $1.isFullURL }) { viewStore in
- Picker(
- selection: viewStore.$isFullURL,
- content: {
- Text("Base URL").tag(false)
- Text("Full URL").tag(true)
- },
- label: { Text("URL") }
- )
- .pickerStyle(.segmented)
- }
+ TextField(text: $store.openAIProjectID, prompt: Text("Optional")) {
+ Text("Project ID")
+ }
+
+ Toggle(isOn: $store.supportsImages) {
+ Text("Supports Images")
+ }
- WithViewStore(store, observe: \.isFullURL) { viewStore in
- baseURLTextField(
- title: "",
- prompt: viewStore.state
- ? Text("https://api.openai.com/v1/chat/completions")
- : Text("https://api.openai.com")
- ) {
- if !viewStore.state {
- Text("/v1/chat/completions")
+ 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)"
+ )
+
+ Text(Image(systemName: "exclamationmark.triangle.fill")) + Text(
+ " If you don't have access to GPT-4, you may need to visit [https://platform.openai.com/account/billing/overview](https://platform.openai.com/account/billing/overview) to buy some credits. A ChatGPT Plus subscription is not enough to access GPT-4 through API."
+ )
}
+ .padding(.vertical)
}
}
+ }
- apiKeyNamePicker
+ struct AzureOpenAIForm: View {
+ @Perception.Bindable var store: StoreOf
+ var body: some View {
+ WithPerceptionTracking {
+ BaseURLTextField(store: store, prompt: Text("https://xxxx.openai.azure.com")) {
+ EmptyView()
+ }
+ ApiKeyNamePicker(store: store)
- WithViewStore(
- store,
- removeDuplicates: { $0.modelName == $1.modelName }
- ) { viewStore in
- TextField("Model Name", text: viewStore.$modelName)
- }
+ TextField("Deployment Name", text: $store.modelName)
- maxTokensTextField
- supportsFunctionCallingToggle
+ MaxTokensTextField(store: store)
+ SupportsFunctionCallingToggle(store: store)
+
+ Toggle(isOn: $store.supportsImages) {
+ Text("Supports Images")
+ }
+ }
+ }
}
- @ViewBuilder
- var googleAI: some View {
- baseURLTextField(prompt: Text("https://generativelanguage.googleapis.com")) {
- Text("/v1")
- }
+ struct OpenAICompatibleForm: View {
+ @Perception.Bindable var store: StoreOf
+
+ var body: some View {
+ WithPerceptionTracking {
+ Picker(
+ selection: $store.baseURLSelection.isFullURL,
+ content: {
+ Text("Base URL").tag(false)
+ Text("Full URL").tag(true)
+ },
+ label: { Text("URL") }
+ )
+ .pickerStyle(.segmented)
+
+ BaseURLTextField(
+ store: store,
+ title: "",
+ prompt: store.isFullURL
+ ? Text("https://api.openai.com/v1/chat/completions")
+ : Text("https://api.openai.com")
+ ) {
+ if !store.isFullURL {
+ Text("/v1/chat/completions")
+ }
+ }
- apiKeyNamePicker
-
- WithViewStore(
- store,
- removeDuplicates: { $0.modelName == $1.modelName }
- ) { viewStore in
- TextField("Model Name", text: viewStore.$modelName)
- .overlay(alignment: .trailing) {
- Picker(
- "",
- selection: viewStore.$modelName,
- content: {
- if GoogleGenerativeAIModel(rawValue: viewStore.state.modelName) == nil {
- Text("Custom Model").tag(viewStore.state.modelName)
- }
- ForEach(GoogleGenerativeAIModel.allCases, id: \.self) { model in
- Text(model.rawValue).tag(model.rawValue)
- }
- }
- )
- .frame(width: 20)
+ ApiKeyNamePicker(store: store)
+
+ TextField("Model Name", text: $store.modelName)
+
+ MaxTokensTextField(store: store)
+ SupportsFunctionCallingToggle(store: store)
+
+ Toggle(isOn: $store.enforceMessageOrder) {
+ Text("Enforce message order to be user/assistant alternated")
}
- }
- maxTokensTextField
+ Toggle(isOn: $store.openAICompatibleSupportsMultipartMessageContent) {
+ Text("Support multi-part message content")
+ }
- WithViewStore(store, removeDuplicates: { $0.apiVersion == $1.apiVersion }) { viewStore in
- TextField("API Version", text: viewStore.$apiVersion, prompt: Text("v1"))
+ Toggle(isOn: $store.requiresBeginWithUserMessage) {
+ Text("Requires the first message to be from the user")
+ }
+
+ Toggle(isOn: $store.supportsImages) {
+ Text("Supports Images")
+ }
+ }
}
}
- @ViewBuilder
- var ollama: some View {
- baseURLTextField(prompt: Text("http://127.0.0.1:11434")) {
- Text("/api/chat")
- }
+ struct GoogleAIForm: View {
+ @Perception.Bindable var store: StoreOf
- WithViewStore(
- store,
- removeDuplicates: { $0.modelName == $1.modelName }
- ) { viewStore in
- TextField("Model Name", text: viewStore.$modelName)
- }
+ var body: some View {
+ WithPerceptionTracking {
+ BaseURLTextField(
+ store: store,
+ prompt: Text("https://generativelanguage.googleapis.com")
+ ) {
+ Text("/v1")
+ }
+
+ ApiKeyNamePicker(store: store)
+
+ TextField("Model Name", text: $store.modelName)
+ .overlay(alignment: .trailing) {
+ Picker(
+ "",
+ selection: $store.modelName,
+ content: {
+ if GoogleGenerativeAIModel(rawValue: store.modelName) == nil {
+ Text("Custom Model").tag(store.modelName)
+ }
+ ForEach(GoogleGenerativeAIModel.allCases, id: \.self) { model in
+ Text(model.rawValue).tag(model.rawValue)
+ }
+ }
+ )
+ .frame(width: 20)
+ }
- maxTokensTextField
+ MaxTokensTextField(store: store)
- WithViewStore(
- store,
- removeDuplicates: { $0.ollamaKeepAlive == $1.ollamaKeepAlive }
- ) { viewStore in
- TextField(text: viewStore.$ollamaKeepAlive, prompt: Text("Default Value")) {
- Text("Keep Alive")
+ TextField("API Version", text: $store.apiVersion, prompt: Text("v1"))
+
+ 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)."
- )
+ 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)
+
+ TextField(text: $store.ollamaKeepAlive, prompt: Text("Default Value")) {
+ 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)."
+ )
+ }
+ .padding(.vertical)
+ }
}
- .padding(.vertical)
}
- @ViewBuilder
- var claude: some View {
- baseURLTextField(prompt: Text("https://api.anthropic.com")) {
- Text("/v1/messages")
- }
+ struct ClaudeForm: View {
+ @Perception.Bindable var store: StoreOf
+ var body: some View {
+ WithPerceptionTracking {
+ BaseURLTextField(store: store, prompt: Text("https://api.anthropic.com")) {
+ Text("/v1/messages")
+ }
- apiKeyNamePicker
-
- WithViewStore(
- store,
- removeDuplicates: { $0.modelName == $1.modelName }
- ) { viewStore in
- TextField("Model Name", text: viewStore.$modelName)
- .overlay(alignment: .trailing) {
- Picker(
- "",
- selection: viewStore.$modelName,
- content: {
- if ClaudeChatCompletionsService
- .KnownModel(rawValue: viewStore.state.modelName) == nil
- {
- Text("Custom Model").tag(viewStore.state.modelName)
+ ApiKeyNamePicker(store: store)
+
+ TextField("Model Name", text: $store.modelName)
+ .overlay(alignment: .trailing) {
+ Picker(
+ "",
+ selection: $store.modelName,
+ content: {
+ if ClaudeChatCompletionsService
+ .KnownModel(rawValue: store.modelName) == nil
+ {
+ Text("Custom Model").tag(store.modelName)
+ }
+ ForEach(
+ ClaudeChatCompletionsService.KnownModel.allCases,
+ id: \.self
+ ) { model in
+ Text(model.rawValue).tag(model.rawValue)
+ }
}
- ForEach(
- ClaudeChatCompletionsService.KnownModel.allCases,
- id: \.self
- ) { model in
- Text(model.rawValue).tag(model.rawValue)
- }
- }
+ )
+ .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)."
)
- .frame(width: 20)
}
+ .padding(.vertical)
+ }
}
+ }
+
+ struct GitHubCopilotForm: View {
+ @Perception.Bindable var store: StoreOf
- maxTokensTextField
+ 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")
+ }
- VStack(alignment: .leading, spacing: 8) {
- Text(Image(systemName: "exclamationmark.triangle.fill")) + Text(
- " For more details, please visit [https://anthropic.com](https://anthropic.com)."
- )
+ 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)
+ }
}
- .padding(.vertical)
}
}
#Preview("OpenAI") {
ChatModelEditView(
store: .init(
- initialState: .init(model: ChatModel(
+ initialState: ChatModel(
id: "3",
name: "Test Model 3",
format: .openAI,
@@ -455,8 +631,8 @@ struct ChatModelEditView: View {
supportsFunctionCalling: false,
modelName: "gpt-3.5-turbo"
)
- )),
- reducer: ChatModelEdit()
+ ).toState(),
+ reducer: { ChatModelEdit() }
)
)
}
@@ -464,7 +640,7 @@ struct ChatModelEditView: View {
#Preview("OpenAI Compatible") {
ChatModelEditView(
store: .init(
- initialState: .init(model: ChatModel(
+ initialState: ChatModel(
id: "3",
name: "Test Model 3",
format: .openAICompatible,
@@ -476,8 +652,8 @@ struct ChatModelEditView: View {
supportsFunctionCalling: false,
modelName: "gpt-3.5-turbo"
)
- )),
- reducer: ChatModelEdit()
+ ).toState(),
+ reducer: { ChatModelEdit() }
)
)
}
diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagement.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagement.swift
index 8fbe3a52..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"
}
}
@@ -37,13 +38,15 @@ extension ChatModel: ManageableAIModel {
}
}
+@Reducer
struct ChatModelManagement: AIModelManagement {
typealias Model = ChatModel
+ @ObservableState
struct State: Equatable, AIModelManagementState {
typealias Model = ChatModel
var models: IdentifiedArrayOf = []
- @PresentationState var editingModel: ChatModelEdit.State?
+ @Presents var editingModel: ChatModelEdit.State?
var selectedModelId: String? { editingModel?.id }
}
@@ -61,7 +64,7 @@ struct ChatModelManagement: AIModelManagement {
@Dependency(\.toast) var toast
@Dependency(\.userDefaults) var userDefaults
- var body: some ReducerProtocol {
+ var body: some ReducerOf {
Reduce { state, action in
switch action {
case .appear:
@@ -89,7 +92,7 @@ struct ChatModelManagement: AIModelManagement {
case let .selectModel(id):
guard let model = state.models[id: id] else { return .none }
- state.editingModel = .init(model: model)
+ state.editingModel = model.toState()
return .none
case let .duplicateModel(id):
@@ -134,7 +137,7 @@ struct ChatModelManagement: AIModelManagement {
case .chatModelItem:
return .none
}
- }.ifLet(\.$editingModel, action: /Action.chatModelItem) {
+ }.ifLet(\.$editingModel, action: \.chatModelItem) {
ChatModelEdit()
}
}
diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagementView.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagementView.swift
index 6101de58..e81b4a97 100644
--- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagementView.swift
+++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagementView.swift
@@ -3,17 +3,19 @@ import ComposableArchitecture
import SwiftUI
struct ChatModelManagementView: View {
- let store: StoreOf
+ @Perception.Bindable var store: StoreOf
var body: some View {
- AIModelManagementView(store: store)
- .sheet(store: store.scope(
- state: \.$editingModel,
- action: ChatModelManagement.Action.chatModelItem
- )) { store in
- ChatModelEditView(store: store)
- .frame(width: 800)
- }
+ WithPerceptionTracking {
+ AIModelManagementView(store: store)
+ .sheet(item: $store.scope(
+ state: \.editingModel,
+ action: \.chatModelItem
+ )) { store in
+ ChatModelEditView(store: store)
+ .frame(width: 800)
+ }
+ }
}
}
@@ -62,23 +64,22 @@ class ChatModelManagementView_Previews: PreviewProvider {
)
),
]),
- editingModel: .init(
- model: ChatModel(
- id: "3",
- name: "Test Model 3",
- format: .openAICompatible,
- info: .init(
- apiKeyName: "key",
- baseURL: "apple.com",
- maxTokens: 3000,
- supportsFunctionCalling: false,
- modelName: "gpt-3.5-turbo"
- )
+ editingModel: ChatModel(
+ id: "3",
+ name: "Test Model 3",
+ format: .openAICompatible,
+ info: .init(
+ apiKeyName: "key",
+ baseURL: "apple.com",
+ maxTokens: 3000,
+ supportsFunctionCalling: false,
+ modelName: "gpt-3.5-turbo"
)
- )
+ ).toState()
),
- reducer: ChatModelManagement()
+ reducer: { ChatModelManagement() }
)
)
}
}
+
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 45ae25fd..f057be21 100644
--- a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift
+++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift
@@ -1,20 +1,23 @@
import AIModel
-import Toast
import ComposableArchitecture
import Dependencies
import Keychain
import OpenAIService
import Preferences
import SwiftUI
+import Toast
-struct EmbeddingModelEdit: ReducerProtocol {
+@Reducer
+struct EmbeddingModelEdit {
+ @ObservableState
struct State: Equatable, Identifiable {
var id: String
- @BindingState var name: String
- @BindingState var format: EmbeddingModel.Format
- @BindingState var maxTokens: Int = 8191
- @BindingState var modelName: String = ""
- @BindingState var ollamaKeepAlive: String = ""
+ 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 }
var baseURL: String { baseURLSelection.baseURL }
var isFullURL: Bool { baseURLSelection.isFullURL }
@@ -24,6 +27,7 @@ struct EmbeddingModelEdit: ReducerProtocol {
var suggestedMaxTokens: Int?
var apiKeySelection: APIKeySelection.State = .init()
var baseURLSelection: BaseURLSelection.State = .init()
+ var customHeaders: [ChatModel.Info.CustomHeaderInfo.HeaderField] = []
}
enum Action: Equatable, BindableAction {
@@ -35,10 +39,37 @@ struct EmbeddingModelEdit: ReducerProtocol {
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
@@ -46,16 +77,17 @@ struct EmbeddingModelEdit: ReducerProtocol {
toast($0, $1, "EmbeddingModelEdit")
}
}
+
@Dependency(\.apiKeyKeychain) var keychain
- var body: some ReducerProtocol {
+ var body: some ReducerOf {
BindingReducer()
- Scope(state: \.apiKeySelection, action: /Action.apiKeySelection) {
+ Scope(state: \.apiKeySelection, action: \.apiKeySelection) {
APIKeySelection()
}
- Scope(state: \.baseURLSelection, action: /Action.baseURLSelection) {
+ Scope(state: \.baseURLSelection, action: \.baseURLSelection) {
BaseURLSelection()
}
@@ -76,6 +108,7 @@ struct EmbeddingModelEdit: ReducerProtocol {
case .testButtonClicked:
guard !state.isTesting else { return .none }
state.isTesting = true
+ let dimensions = state.dimensions
let model = EmbeddingModel(
id: state.id,
name: state.name,
@@ -85,18 +118,33 @@ struct EmbeddingModelEdit: ReducerProtocol {
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))
}
@@ -127,6 +175,34 @@ struct EmbeddingModelEdit: ReducerProtocol {
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:
@@ -135,13 +211,13 @@ struct EmbeddingModelEdit: ReducerProtocol {
case .baseURLSelection:
return .none
- case .binding(\.$format):
+ case .binding(\.format):
return .run { send in
await send(.refreshAvailableModelNames)
await send(.checkSuggestedMaxTokens)
}
- case .binding(\.$modelName):
+ case .binding(\.modelName):
return .run { send in
await send(.checkSuggestedMaxTokens)
}
@@ -153,24 +229,6 @@ struct EmbeddingModelEdit: ReducerProtocol {
}
}
-extension EmbeddingModelEdit.State {
- init(model: EmbeddingModel) {
- self.init(
- id: model.id,
- name: model.name,
- format: model.format,
- maxTokens: model.info.maxTokens,
- modelName: model.info.modelName,
- ollamaKeepAlive: model.info.ollamaInfo.keepAlive,
- apiKeySelection: .init(
- apiKeyName: model.info.apiKeyName,
- apiKeyManagement: .init(availableAPIKeyNames: [model.info.apiKeyName])
- ),
- baseURLSelection: .init(baseURL: model.info.baseURL, isFullURL: model.info.isFullURL)
- )
- }
-}
-
extension EmbeddingModel {
init(state: EmbeddingModelEdit.State) {
self.init(
@@ -182,10 +240,33 @@ 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)
)
)
}
+
+ func toState() -> EmbeddingModelEdit.State {
+ .init(
+ id: id,
+ name: name,
+ format: format,
+ maxTokens: info.maxTokens,
+ dimensions: info.dimensions,
+ modelName: info.modelName,
+ ollamaKeepAlive: info.ollamaInfo.keepAlive,
+ apiKeySelection: .init(
+ apiKeyName: info.apiKeyName,
+ apiKeyManagement: .init(availableAPIKeyNames: [info.apiKeyName])
+ ),
+ 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 ca7037e2..46f4effd 100644
--- a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift
+++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift
@@ -5,326 +5,434 @@ import SwiftUI
@MainActor
struct EmbeddingModelEditView: View {
- let store: StoreOf
+ @Perception.Bindable var store: StoreOf
var body: some View {
- ScrollView {
- VStack(spacing: 0) {
- Form {
- nameTextField
- formatPicker
-
- WithViewStore(store, observe: { $0.format }) { viewStore in
- switch viewStore.state {
+ WithPerceptionTracking {
+ ScrollView {
+ VStack(spacing: 0) {
+ Form {
+ NameTextField(store: store)
+ FormatPicker(store: store)
+
+ switch store.format {
case .openAI:
- openAI
+ OpenAIForm(store: store)
case .azureOpenAI:
- azureOpenAI
+ AzureOpenAIForm(store: store)
case .openAICompatible:
- openAICompatible
+ OpenAICompatibleForm(store: store)
case .ollama:
- ollama
+ OllamaForm(store: store)
+ case .gitHubCopilot:
+ GitHubCopilotForm(store: store)
}
}
- }
- .padding()
+ .padding()
- Divider()
+ Divider()
- HStack {
- WithViewStore(store, observe: { $0.isTesting }) { viewStore in
+ HStack {
HStack(spacing: 8) {
Button("Test") {
store.send(.testButtonClicked)
}
- .disabled(viewStore.state)
+ .disabled(store.isTesting)
- if viewStore.state {
+ if store.isTesting {
ProgressView()
.controlSize(.small)
}
}
- }
- Spacer()
+ Spacer()
- Button("Cancel") {
- store.send(.cancelButtonClicked)
- }
- .keyboardShortcut(.cancelAction)
+ Button("Cancel") {
+ store.send(.cancelButtonClicked)
+ }
+ .keyboardShortcut(.cancelAction)
- Button(action: { store.send(.saveButtonClicked) }) {
- Text("Save")
+ Button(action: { store.send(.saveButtonClicked) }) {
+ Text("Save")
+ }
+ .keyboardShortcut(.defaultAction)
}
- .keyboardShortcut(.defaultAction)
+ .padding()
}
- .padding()
}
+ .textFieldStyle(.roundedBorder)
+ .onAppear {
+ store.send(.appear)
+ }
+ .fixedSize(horizontal: false, vertical: true)
+ .handleToast(namespace: "EmbeddingModelEdit")
}
- .textFieldStyle(.roundedBorder)
- .onAppear {
- store.send(.appear)
- }
- .fixedSize(horizontal: false, vertical: true)
- .handleToast(namespace: "EmbeddingModelEdit")
}
- var nameTextField: some View {
- WithViewStore(store, removeDuplicates: { $0.name == $1.name }) { viewStore in
- TextField("Name", text: viewStore.$name)
+ struct NameTextField: View {
+ @Perception.Bindable var store: StoreOf
+ var body: some View {
+ WithPerceptionTracking {
+ TextField("Name", text: $store.name)
+ }
}
}
- var formatPicker: some View {
- WithViewStore(store, removeDuplicates: { $0.format == $1.format }) { viewStore in
- Picker(
- selection: viewStore.$format,
- content: {
- ForEach(
- EmbeddingModel.Format.allCases,
- id: \.rawValue
- ) { format in
- switch format {
- case .openAI:
- Text("OpenAI").tag(format)
- case .azureOpenAI:
- Text("Azure OpenAI").tag(format)
- case .openAICompatible:
- Text("OpenAI Compatible").tag(format)
- case .ollama:
- Text("Ollama").tag(format)
+ struct FormatPicker: View {
+ @Perception.Bindable var store: StoreOf
+ var body: some View {
+ WithPerceptionTracking {
+ Picker(
+ selection: Binding(
+ get: { .init(store.format) },
+ set: { store.send(.selectModelFormat($0)) }
+ ),
+ content: {
+ ForEach(
+ EmbeddingModelEdit.ModelFormat.allCases,
+ id: \.self
+ ) { format in
+ switch format {
+ case .openAI:
+ Text("OpenAI")
+ case .azureOpenAI:
+ Text("Azure OpenAI")
+ case .ollama:
+ 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)
+ },
+ label: { Text("Format") }
+ )
+ .pickerStyle(.menu)
+ }
}
}
- func baseURLTextField(
- title: String = "Base URL",
- prompt: Text?,
- @ViewBuilder trailingContent: @escaping () -> V
- ) -> some View {
- BaseURLPicker(
- title: title,
- prompt: prompt,
- store: store.scope(
- state: \.baseURLSelection,
- action: EmbeddingModelEdit.Action.baseURLSelection
- ),
- trailingContent: trailingContent
- )
+ struct BaseURLTextField: View {
+ let store: StoreOf
+ var title: String = "Base URL"
+ let prompt: Text?
+ @ViewBuilder var trailingContent: () -> V
+
+ var body: some View {
+ WithPerceptionTracking {
+ BaseURLPicker(
+ title: title,
+ prompt: prompt,
+ store: store.scope(
+ state: \.baseURLSelection,
+ action: \.baseURLSelection
+ ),
+ trailingContent: trailingContent
+ )
+ }
+ }
}
- func baseURLTextField(
- title: String = "Base URL",
- prompt: Text?
- ) -> some View {
- baseURLTextField(title: title, prompt: prompt, trailingContent: { EmptyView() })
- }
+ struct MaxTokensTextField: View {
+ @Perception.Bindable var store: StoreOf
- struct MaxTokensTextField: Equatable {
- @BindingViewState var maxTokens: Int
- var suggestedMaxTokens: Int?
- }
+ var body: some View {
+ WithPerceptionTracking {
+ HStack {
+ let textFieldBinding = Binding(
+ get: { String(store.maxTokens) },
+ set: {
+ if let selectionMaxToken = Int($0) {
+ $store.maxTokens.wrappedValue = selectionMaxToken
+ } else {
+ $store.maxTokens.wrappedValue = 0
+ }
+ }
+ )
- var maxTokensTextField: some View {
- WithViewStore(
- store,
- observe: {
- MaxTokensTextField(
- maxTokens: $0.$maxTokens,
- suggestedMaxTokens: $0.suggestedMaxTokens
- )
- }
- ) { viewStore in
- HStack {
- let textFieldBinding = Binding(
- get: { String(viewStore.state.maxTokens) },
- set: {
- if let selectionMaxToken = Int($0) {
- viewStore.$maxTokens.wrappedValue = selectionMaxToken
- } else {
- viewStore.$maxTokens.wrappedValue = 0
+ TextField(text: textFieldBinding) {
+ Text("Max Input Tokens")
+ .multilineTextAlignment(.trailing)
+ }
+ .overlay(alignment: .trailing) {
+ Stepper(
+ value: $store.maxTokens,
+ in: 0...Int.max,
+ step: 100
+ ) {
+ EmptyView()
}
}
- )
+ .foregroundColor({
+ guard let max = store.suggestedMaxTokens else {
+ return .primary
+ }
+ if store.maxTokens > max {
+ return .red
+ }
+ return .primary
+ }() as Color)
- TextField(text: textFieldBinding) {
- Text("Max Input Tokens")
- .multilineTextAlignment(.trailing)
- }
- .overlay(alignment: .trailing) {
- Stepper(
- value: viewStore.$maxTokens,
- in: 0...Int.max,
- step: 100
- ) {
- EmptyView()
+ if let max = store.suggestedMaxTokens {
+ Text("Max: \(max)")
}
}
- .foregroundColor({
- guard let max = viewStore.state.suggestedMaxTokens else {
- return .primary
+ }
+ }
+ }
+
+ 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)
}
- if viewStore.state.maxTokens > max {
- return .red
+ .overlay(alignment: .trailing) {
+ Stepper(
+ value: $store.dimensions,
+ in: 0...Int.max,
+ step: 100
+ ) {
+ EmptyView()
+ }
}
- return .primary
- }() as Color)
-
- if let max = viewStore.state.suggestedMaxTokens {
- Text("Max: \(max)")
+ .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 APIKeyState: Equatable {
- @BindingViewState var apiKeyName: String
- var availableAPIKeys: [String]
+ struct ApiKeyNamePicker: View {
+ let store: StoreOf
+ var body: some View {
+ WithPerceptionTracking {
+ APIKeyPicker(store: store.scope(
+ state: \.apiKeySelection,
+ action: \.apiKeySelection
+ ))
+ }
+ }
}
- @ViewBuilder
- var apiKeyNamePicker: some View {
- APIKeyPicker(store: store.scope(
- state: \.apiKeySelection,
- action: EmbeddingModelEdit.Action.apiKeySelection
- ))
- }
+ struct OpenAIForm: View {
+ @Perception.Bindable var store: StoreOf
- @ViewBuilder
- var openAI: some View {
- baseURLTextField(prompt: Text("https://api.openai.com")) {
- Text("/v1/embeddings")
- }
- apiKeyNamePicker
-
- WithViewStore(
- store,
- removeDuplicates: { $0.modelName == $1.modelName }
- ) { viewStore in
- TextField("Model Name", text: viewStore.$modelName)
- .overlay(alignment: .trailing) {
- Picker(
- "",
- selection: viewStore.$modelName,
- content: {
- if OpenAIEmbeddingModel(rawValue: viewStore.state.modelName) == nil {
- Text("Custom Model").tag(viewStore.state.modelName)
- }
- ForEach(OpenAIEmbeddingModel.allCases, id: \.self) { model in
- Text(model.rawValue).tag(model.rawValue)
+ var body: some View {
+ WithPerceptionTracking {
+ BaseURLTextField(store: store, prompt: Text("https://api.openai.com")) {
+ Text("/v1/embeddings")
+ }
+ ApiKeyNamePicker(store: store)
+
+ 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)
+
+ 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)"
+ )
+
+ Text(Image(systemName: "exclamationmark.triangle.fill")) + Text(
+ " If you don't have access to GPT-4, you may need to visit [https://platform.openai.com/account/billing/overview](https://platform.openai.com/account/billing/overview) to buy some credits. A ChatGPT Plus subscription is not enough to access GPT-4 through API."
)
- .frame(width: 20)
}
+ .padding(.vertical)
+ }
}
+ }
- maxTokensTextField
-
- 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)"
- )
+ struct AzureOpenAIForm: View {
+ @Perception.Bindable var store: StoreOf
+ var body: some View {
+ WithPerceptionTracking {
+ BaseURLTextField(store: store, prompt: Text("https://xxxx.openai.azure.com")) {
+ EmptyView()
+ }
+ ApiKeyNamePicker(store: store)
- Text(Image(systemName: "exclamationmark.triangle.fill")) + Text(
- " If you don't have access to GPT-4, you may need to visit [https://platform.openai.com/account/billing/overview](https://platform.openai.com/account/billing/overview) to buy some credits. A ChatGPT Plus subscription is not enough to access GPT-4 through API."
- )
+ TextField("Deployment Name", text: $store.modelName)
+
+ MaxTokensTextField(store: store)
+ DimensionsTextField(store: store)
+ }
}
- .padding(.vertical)
}
- @ViewBuilder
- var azureOpenAI: some View {
- baseURLTextField(prompt: Text("https://xxxx.openai.azure.com"))
- apiKeyNamePicker
+ struct OpenAICompatibleForm: View {
+ @Perception.Bindable var store: StoreOf
+ @State var isEditingCustomHeader = false
+
+ var body: some View {
+ WithPerceptionTracking {
+ Picker(
+ selection: $store.baseURLSelection.isFullURL,
+ content: {
+ Text("Base URL").tag(false)
+ Text("Full URL").tag(true)
+ },
+ label: { Text("URL") }
+ )
+ .pickerStyle(.segmented)
+
+ BaseURLTextField(
+ store: store,
+ title: "",
+ prompt: store.isFullURL
+ ? Text("https://api.openai.com/v1/embeddings")
+ : Text("https://api.openai.com")
+ ) {
+ if !store.isFullURL {
+ Text("/v1/embeddings")
+ }
+ }
- WithViewStore(
- store,
- removeDuplicates: { $0.modelName == $1.modelName }
- ) { viewStore in
- TextField("Deployment Name", text: viewStore.$modelName)
- }
+ ApiKeyNamePicker(store: store)
- maxTokensTextField
- }
+ TextField("Model Name", text: $store.modelName)
- @ViewBuilder
- var openAICompatible: some View {
- WithViewStore(store.scope(
- state: \.baseURLSelection,
- action: EmbeddingModelEdit.Action.baseURLSelection
- ), removeDuplicates: { $0.isFullURL != $1.isFullURL }) { viewStore in
- Picker(
- selection: viewStore.$isFullURL,
- content: {
- Text("Base URL").tag(false)
- Text("Full URL").tag(true)
- },
- label: { Text("URL") }
- )
- .pickerStyle(.segmented)
- }
+ MaxTokensTextField(store: store)
+ DimensionsTextField(store: store)
- WithViewStore(store, observe: \.isFullURL) { viewStore in
- baseURLTextField(
- title: "",
- prompt: viewStore.state
- ? Text("https://api.openai.com/v1/embeddings")
- : Text("https://api.openai.com")
- ) {
- if !viewStore.state {
- Text("/v1/embeddings")
+ Button("Custom Headers") {
+ isEditingCustomHeader.toggle()
}
+ }.sheet(isPresented: $isEditingCustomHeader) {
+ CustomHeaderSettingsView(headers: $store.customHeaders)
}
}
+ }
- apiKeyNamePicker
+ struct OllamaForm: View {
+ @Perception.Bindable var store: StoreOf
+ @State var isEditingCustomHeader = false
- WithViewStore(
- store,
- removeDuplicates: { $0.modelName == $1.modelName }
- ) { viewStore in
- TextField("Model Name", text: viewStore.$modelName)
- }
+ var body: some View {
+ WithPerceptionTracking {
+ BaseURLTextField(store: store, prompt: Text("http://127.0.0.1:11434")) {
+ Text("/api/embeddings")
+ }
- maxTokensTextField
- }
-
- @ViewBuilder
- var ollama: some View {
- baseURLTextField(prompt: Text("http://127.0.0.1:11434")) {
- Text("/api/embeddings")
- }
+ ApiKeyNamePicker(store: store)
- WithViewStore(
- store,
- removeDuplicates: { $0.modelName == $1.modelName }
- ) { viewStore in
- TextField("Model Name", text: viewStore.$modelName)
- }
+ TextField("Model Name", text: $store.modelName)
+
+ MaxTokensTextField(store: store)
+ DimensionsTextField(store: store)
+
+ WithPerceptionTracking {
+ TextField(text: $store.ollamaKeepAlive, prompt: Text("Default Value")) {
+ Text("Keep Alive")
+ }
+ }
+
+ Button("Custom Headers") {
+ isEditingCustomHeader.toggle()
+ }
- maxTokensTextField
-
- WithViewStore(
- store,
- removeDuplicates: { $0.ollamaKeepAlive == $1.ollamaKeepAlive }
- ) { viewStore in
- TextField(text: viewStore.$ollamaKeepAlive, prompt: Text("Default Value")) {
- Text("Keep Alive")
+ 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)
}
}
-
- VStack(alignment: .leading, spacing: 8) {
- Text(Image(systemName: "exclamationmark.triangle.fill")) + Text(
- " For more details, please visit [https://ollama.com](https://ollama.com)."
- )
+ }
+
+ 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)
+ }
}
- .padding(.vertical)
}
}
@@ -332,7 +440,7 @@ class EmbeddingModelManagementView_Editing_Previews: PreviewProvider {
static var previews: some View {
EmbeddingModelEditView(
store: .init(
- initialState: .init(model: EmbeddingModel(
+ initialState: EmbeddingModel(
id: "3",
name: "Test Model 3",
format: .openAICompatible,
@@ -342,8 +450,8 @@ class EmbeddingModelManagementView_Editing_Previews: PreviewProvider {
maxTokens: 3000,
modelName: "gpt-3.5-turbo"
)
- )),
- reducer: EmbeddingModelEdit()
+ ).toState(),
+ reducer: { EmbeddingModelEdit() }
)
)
}
diff --git a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagement.swift b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagement.swift
index 71b0d4a5..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"
}
}
@@ -29,13 +30,15 @@ extension EmbeddingModel: ManageableAIModel {
}
}
+@Reducer
struct EmbeddingModelManagement: AIModelManagement {
typealias Model = EmbeddingModel
+ @ObservableState
struct State: Equatable, AIModelManagementState {
typealias Model = EmbeddingModel
var models: IdentifiedArrayOf = []
- @PresentationState var editingModel: EmbeddingModelEdit.State?
+ @Presents var editingModel: EmbeddingModelEdit.State?
var selectedModelId: Model.ID? { editingModel?.id }
}
@@ -53,7 +56,7 @@ struct EmbeddingModelManagement: AIModelManagement {
@Dependency(\.toast) var toast
@Dependency(\.userDefaults) var userDefaults
- var body: some ReducerProtocol {
+ var body: some ReducerOf {
Reduce { state, action in
switch action {
case .appear:
@@ -81,7 +84,7 @@ struct EmbeddingModelManagement: AIModelManagement {
case let .selectModel(id):
guard let model = state.models[id: id] else { return .none }
- state.editingModel = .init(model: model)
+ state.editingModel = model.toState()
return .none
case let .duplicateModel(id):
@@ -126,7 +129,7 @@ struct EmbeddingModelManagement: AIModelManagement {
case .embeddingModelItem:
return .none
}
- }.ifLet(\.$editingModel, action: /Action.embeddingModelItem) {
+ }.ifLet(\.$editingModel, action: \.embeddingModelItem) {
EmbeddingModelEdit()
}
}
diff --git a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagementView.swift b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagementView.swift
index a3bfa16c..e251af10 100644
--- a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagementView.swift
+++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagementView.swift
@@ -3,17 +3,19 @@ import ComposableArchitecture
import SwiftUI
struct EmbeddingModelManagementView: View {
- let store: StoreOf
+ @Perception.Bindable var store: StoreOf
var body: some View {
- AIModelManagementView(store: store)
- .sheet(store: store.scope(
- state: \.$editingModel,
- action: EmbeddingModelManagement.Action.embeddingModelItem
- )) { store in
- EmbeddingModelEditView(store: store)
- .frame(width: 800)
- }
+ WithPerceptionTracking {
+ AIModelManagementView(store: store)
+ .sheet(item: $store.scope(
+ state: \.editingModel,
+ action: \.embeddingModelItem
+ )) { store in
+ EmbeddingModelEditView(store: store)
+ .frame(width: 800)
+ }
+ }
}
}
@@ -59,22 +61,21 @@ class EmbeddingModelManagementView_Previews: PreviewProvider {
)
),
]),
- editingModel: .init(
- model: EmbeddingModel(
- id: "3",
- name: "Test Model 3",
- format: .openAICompatible,
- info: .init(
- apiKeyName: "key",
- baseURL: "apple.com",
- maxTokens: 3000,
- modelName: "gpt-3.5-turbo"
- )
+ editingModel: EmbeddingModel(
+ id: "3",
+ name: "Test Model 3",
+ format: .openAICompatible,
+ info: .init(
+ apiKeyName: "key",
+ baseURL: "apple.com",
+ maxTokens: 3000,
+ modelName: "gpt-3.5-turbo"
)
- )
+ ).toState()
),
- reducer: EmbeddingModelManagement()
+ reducer: { EmbeddingModelManagement() }
)
)
}
}
+
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 90a8bdf5..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(\.gitHubCopilotIgnoreTrailingNewLines)
- var gitHubCopilotIgnoreTrailingNewLines
+ @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,6 +200,10 @@ struct GitHubCopilotView: View {
.foregroundColor(.secondary)
.font(.callout)
.dynamicHeightTextInFormWorkaround()
+
+ Toggle(isOn: $settings.gitHubCopilotLoadKeyChainCertificates) {
+ Text("Load certificates in keychain")
+ }
}
}
@@ -214,7 +220,7 @@ struct GitHubCopilotView: View {
case let .installed(version):
Text("Copilot.Vim Version: \(version)")
uninstallButton
- case let .outdated(version, latest):
+ case let .outdated(version, latest, _):
Text("Copilot.Vim Version: \(version) (Update Available: \(latest))")
updateButton
uninstallButton
@@ -255,30 +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(
- "Remove Extra New Lines Generated by GitHub Copilot",
- isOn: $settings.gitHubCopilotIgnoreTrailingNewLines
- )
- Text(
- "Sometimes GitHub Copilot may generate extra unwanted new lines at the end of a suggestion. If you don't like that, you can turn this toggle on."
- )
- .lineLimit(10)
- .foregroundColor(.secondary)
- .font(.callout)
- .dynamicHeightTextInFormWorkaround()
- Toggle("Verbose Log", isOn: $settings.gitHubCopilotVerboseLog)
+ Toggle("Verbose log", isOn: $settings.gitHubCopilotVerboseLog)
+ Toggle("Pretend IDE to be VSCode", isOn: $settings.pretendIDEToBeVSCode)
}
SettingsDivider("Enterprise")
@@ -288,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")
}
}
@@ -299,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()
@@ -354,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/OtherSuggestionServicesView.swift b/Core/Sources/HostApp/AccountSettings/OtherSuggestionServicesView.swift
new file mode 100644
index 00000000..2497c9b5
--- /dev/null
+++ b/Core/Sources/HostApp/AccountSettings/OtherSuggestionServicesView.swift
@@ -0,0 +1,31 @@
+import Foundation
+import SwiftUI
+
+struct OtherSuggestionServicesView: View {
+ @Environment(\.openURL) var openURL
+ var body: some View {
+ VStack(alignment: .leading) {
+ Text(
+ "You can use other locally run services (Tabby, Ollma, etc.) to generate suggestions with the Custom Suggestion Service extension."
+ )
+ .lineLimit(nil)
+ .multilineTextAlignment(.leading)
+
+ Button(action: {
+ if let url = URL(
+ string: "https://github.com/intitni/CustomSuggestionServiceForCopilotForXcode"
+ ) {
+ openURL(url)
+ }
+ }) {
+ Text("Get It Now")
+ }
+ }
+ }
+}
+
+#Preview {
+ OtherSuggestionServicesView()
+ .frame(width: 200)
+}
+
diff --git a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/AIModelManagementVIew.swift b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/AIModelManagementVIew.swift
index 621ed75d..2c1fd2d7 100644
--- a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/AIModelManagementVIew.swift
+++ b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/AIModelManagementVIew.swift
@@ -20,9 +20,9 @@ protocol AIModelManagementState: Equatable {
var selectedModelId: Model.ID? { get }
}
-protocol AIModelManagement: ReducerProtocol where
+protocol AIModelManagement: Reducer where
Action: AIModelManagementAction,
- State: AIModelManagementState,
+ State: AIModelManagementState & ObservableState,
Action.Model == Self.Model,
State.Model == Self.Model
{
@@ -39,69 +39,71 @@ protocol ManageableAIModel: Identifiable {
struct AIModelManagementView: View
where Management.Model == Model
{
- let store: StoreOf
+ @Perception.Bindable var store: StoreOf
var body: some View {
- VStack(spacing: 0) {
- HStack {
- Spacer()
- if isFeatureAvailable(\.unlimitedChatAndEmbeddingModels) {
- Button("Add Model") {
- store.send(.createModel)
- }
- } else {
- WithViewStore(store, observe: { $0.models.count }) { viewStore in
- Text("\(viewStore.state) / 2")
+ WithPerceptionTracking {
+ VStack(spacing: 0) {
+ HStack {
+ Spacer()
+ if isFeatureAvailable(\.unlimitedChatAndEmbeddingModels) {
+ Button("Add Model") {
+ store.send(.createModel)
+ }
+ } else {
+ Text("\(store.models.count) / 2")
.foregroundColor(.secondary)
- let disabled = viewStore.state >= 2
+ let disabled = store.models.count >= 2
Button(disabled ? "Add More Model (Plus)" : "Add Model") {
store.send(.createModel)
}.disabled(disabled)
}
- }
- }.padding(4)
+ }.padding(4)
- Divider()
+ Divider()
- ModelList(store: store)
- }
- .onAppear {
- store.send(.appear)
+ ModelList(store: store)
+ }
+ .onAppear {
+ store.send(.appear)
+ }
}
}
struct ModelList: View {
- let store: StoreOf
+ @Perception.Bindable var store: StoreOf
var body: some View {
- WithViewStore(store) { viewStore in
+ WithPerceptionTracking {
List {
- ForEach(viewStore.state.models) { model in
- let isSelected = viewStore.state.selectedModelId == model.id
- HStack(spacing: 4) {
- Image(systemName: "line.3.horizontal")
+ ForEach(store.models) { model in
+ WithPerceptionTracking {
+ let isSelected = store.selectedModelId == model.id
+ HStack(spacing: 4) {
+ Image(systemName: "line.3.horizontal")
- Button(action: {
- viewStore.send(.selectModel(id: model.id))
- }) {
- Cell(model: model, isSelected: isSelected)
- .contentShape(Rectangle())
- }
- .buttonStyle(.plain)
- .contextMenu {
- Button("Duplicate") {
- store.send(.duplicateModel(id: model.id))
+ Button(action: {
+ store.send(.selectModel(id: model.id))
+ }) {
+ Cell(model: model, isSelected: isSelected)
+ .contentShape(Rectangle())
}
- Button("Remove") {
- store.send(.removeModel(id: model.id))
+ .buttonStyle(.plain)
+ .contextMenu {
+ Button("Duplicate") {
+ store.send(.duplicateModel(id: model.id))
+ }
+ Button("Remove") {
+ store.send(.removeModel(id: model.id))
+ }
}
}
}
}
.onMove(perform: { indices, newOffset in
- viewStore.send(.moveModel(from: indices, to: newOffset))
+ store.send(.moveModel(from: indices, to: newOffset))
})
.modify { view in
if #available(macOS 13.0, *) {
@@ -115,7 +117,7 @@ struct AIModelManagementView(
store: .init(
- initialState: .init(models: []),
- reducer: ChatModelManagement()
+ initialState: .init(models: [] as IdentifiedArrayOf),
+ reducer: { ChatModelManagement() }
)
)
}
diff --git a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLPicker.swift b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLPicker.swift
index 066983e7..9456946e 100644
--- a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLPicker.swift
+++ b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLPicker.swift
@@ -4,40 +4,40 @@ import SwiftUI
struct BaseURLPicker: View {
let title: String
let prompt: Text?
- let store: StoreOf
+ @Perception.Bindable var store: StoreOf
@ViewBuilder let trailingContent: () -> TrailingContent
-
+
var body: some View {
- WithViewStore(store) { viewStore in
+ WithPerceptionTracking {
HStack {
- TextField(title, text: viewStore.$baseURL, prompt: prompt)
+ TextField(title, text: $store.baseURL, prompt: prompt)
.overlay(alignment: .trailing) {
Picker(
"",
- selection: viewStore.$baseURL,
+ selection: $store.baseURL,
content: {
- if !viewStore.state.availableBaseURLs
- .contains(viewStore.state.baseURL),
- !viewStore.state.baseURL.isEmpty
+ if !store.availableBaseURLs
+ .contains(store.baseURL),
+ !store.baseURL.isEmpty
{
- Text("Custom Value").tag(viewStore.state.baseURL)
+ Text("Custom Value").tag(store.baseURL)
}
-
+
Text("Empty (Default Value)").tag("")
-
- ForEach(viewStore.state.availableBaseURLs, id: \.self) { baseURL in
+
+ ForEach(store.availableBaseURLs, id: \.self) { baseURL in
Text(baseURL).tag(baseURL)
}
}
)
.frame(width: 20)
}
-
+
trailingContent()
.foregroundStyle(.secondary)
}
.onAppear {
- viewStore.send(.appear)
+ store.send(.appear)
}
}
}
@@ -57,3 +57,4 @@ extension BaseURLPicker where TrailingContent == EmptyView {
)
}
}
+
diff --git a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLSelection.swift b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLSelection.swift
index daff8e21..502d79a7 100644
--- a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLSelection.swift
+++ b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLSelection.swift
@@ -3,10 +3,12 @@ import Foundation
import Preferences
import SwiftUI
-struct BaseURLSelection: ReducerProtocol {
+@Reducer
+struct BaseURLSelection {
+ @ObservableState
struct State: Equatable {
- @BindingState var baseURL: String = ""
- @BindingState var isFullURL: Bool = false
+ var baseURL: String = ""
+ var isFullURL: Bool = false
var availableBaseURLs: [String] = []
}
@@ -19,7 +21,7 @@ struct BaseURLSelection: ReducerProtocol {
@Dependency(\.toast) var toast
@Dependency(\.userDefaults) var userDefaults
- var body: some ReducerProtocol {
+ var body: some ReducerOf {
BindingReducer()
Reduce { state, action in
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/CustomCommand.swift b/Core/Sources/HostApp/CustomCommandSettings/CustomCommand.swift
index 212b8313..884c58f0 100644
--- a/Core/Sources/HostApp/CustomCommandSettings/CustomCommand.swift
+++ b/Core/Sources/HostApp/CustomCommandSettings/CustomCommand.swift
@@ -5,7 +5,9 @@ import Preferences
import SwiftUI
import Toast
-struct CustomCommandFeature: ReducerProtocol {
+@Reducer
+struct CustomCommandFeature {
+ @ObservableState
struct State: Equatable {
var editCustomCommand: EditCustomCommand.State?
}
@@ -24,7 +26,7 @@ struct CustomCommandFeature: ReducerProtocol {
@Dependency(\.toast) var toast
- var body: some ReducerProtocol {
+ var body: some ReducerOf {
Reduce { state, action in
switch action {
case .createNewCommand:
@@ -122,7 +124,7 @@ struct CustomCommandFeature: ReducerProtocol {
}
}
}
- }.ifLet(\.editCustomCommand, action: /Action.editCustomCommand) {
+ }.ifLet(\.editCustomCommand, action: \.editCustomCommand) {
EditCustomCommand(settings: settings)
}
}
diff --git a/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift b/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift
index 22594715..033b9850 100644
--- a/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift
+++ b/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift
@@ -21,9 +21,7 @@ extension List {
let customCommandStore = StoreOf(
initialState: .init(),
- reducer: CustomCommandFeature(
- settings: .init()
- )
+ reducer: { CustomCommandFeature(settings: .init()) }
)
struct CustomCommandView: View {
@@ -43,63 +41,70 @@ struct CustomCommandView: View {
var body: some View {
HStack(spacing: 0) {
- leftPane
+ LeftPanel(store: store, settings: settings)
Divider()
- rightPane
+ RightPanel(store: store)
}
}
- @ViewBuilder
- var leftPane: some View {
- List {
- ForEach(settings.customCommands, id: \.commandId) { command in
- CommandButton(store: store, command: command)
- }
- .onMove(perform: { indices, newOffset in
- settings.customCommands.move(fromOffsets: indices, toOffset: newOffset)
- })
- .modify { view in
- if #available(macOS 13.0, *) {
- view.listRowSeparator(.hidden).listSectionSeparator(.hidden)
- } else {
- view
+ struct LeftPanel: View {
+ let store: StoreOf
+ @ObservedObject var settings: Settings
+ @Environment(\.toast) var toast
+
+ var body: some View {
+ WithPerceptionTracking {
+ List {
+ ForEach(settings.customCommands, id: \.commandId) { command in
+ CommandButton(store: store, command: command)
+ }
+ .onMove(perform: { indices, newOffset in
+ settings.customCommands.move(fromOffsets: indices, toOffset: newOffset)
+ })
+ .modify { view in
+ if #available(macOS 13.0, *) {
+ view.listRowSeparator(.hidden).listSectionSeparator(.hidden)
+ } else {
+ view
+ }
+ }
}
- }
- }
- .removeBackground()
- .padding(.vertical, 4)
- .listStyle(.plain)
- .frame(width: 200)
- .background(Color.primary.opacity(0.05))
- .overlay {
- if settings.customCommands.isEmpty {
- Text("""
- Empty
- Add command with "+" button
- """)
- .multilineTextAlignment(.center)
- }
- }
- .safeAreaInset(edge: .bottom) {
- Button(action: {
- store.send(.createNewCommand)
- }) {
- if isFeatureAvailable(\.unlimitedCustomCommands) {
- Text(Image(systemName: "plus.circle.fill")) + Text(" New Command")
- } else {
- Text(Image(systemName: "plus.circle.fill")) +
- Text(" New Command (\(settings.customCommands.count)/10)")
+ .removeBackground()
+ .padding(.vertical, 4)
+ .listStyle(.plain)
+ .frame(width: 200)
+ .background(Color.primary.opacity(0.05))
+ .overlay {
+ if settings.customCommands.isEmpty {
+ Text("""
+ Empty
+ Add command with "+" button
+ """)
+ .multilineTextAlignment(.center)
+ }
}
- }
- .buttonStyle(.plain)
- .padding()
- .contextMenu {
- Button("Import") {
- store.send(.importCommandClicked)
+ .safeAreaInset(edge: .bottom) {
+ Button(action: {
+ store.send(.createNewCommand)
+ }) {
+ if isFeatureAvailable(\.unlimitedCustomCommands) {
+ Text(Image(systemName: "plus.circle.fill")) + Text(" New Command")
+ } else {
+ Text(Image(systemName: "plus.circle.fill")) +
+ Text(" New Command (\(settings.customCommands.count)/10)")
+ }
+ }
+ .buttonStyle(.plain)
+ .padding()
+ .contextMenu {
+ Button("Import") {
+ store.send(.importCommandClicked)
+ }
+ }
}
+ .onDrop(of: [.json], delegate: FileDropDelegate(store: store, toast: toast))
}
}
- .onDrop(of: [.json], delegate: FileDropDelegate(store: store, toast: toast))
}
struct FileDropDelegate: DropDelegate {
@@ -108,15 +113,16 @@ struct CustomCommandView: View {
func performDrop(info: DropInfo) -> Bool {
let jsonFiles = info.itemProviders(for: [.json])
for file in jsonFiles {
- file.loadInPlaceFileRepresentation(forTypeIdentifier: "public.json") { url, _, error in
- Task { @MainActor in
- if let url {
- store.send(.importCommand(at: url))
- } else if let error {
- toast(error.localizedDescription, .error)
+ file
+ .loadInPlaceFileRepresentation(forTypeIdentifier: "public.json") { url, _, error in
+ Task { @MainActor in
+ if let url {
+ store.send(.importCommand(at: url))
+ } else if let error {
+ toast(error.localizedDescription, .error)
+ }
}
}
- }
}
return !jsonFiles.isEmpty
@@ -124,92 +130,96 @@ struct CustomCommandView: View {
}
struct CommandButton: View {
- let store: StoreOf
+ @Perception.Bindable var store: StoreOf
let command: CustomCommand
var body: some View {
- HStack(spacing: 4) {
- Image(systemName: "line.3.horizontal")
+ WithPerceptionTracking {
+ HStack(spacing: 4) {
+ Image(systemName: "line.3.horizontal")
- VStack(alignment: .leading) {
- Text(command.name)
- .foregroundStyle(.primary)
+ VStack(alignment: .leading) {
+ Text(command.name)
+ .foregroundStyle(.primary)
- Group {
- switch command.feature {
- case .chatWithSelection:
- Text("Send Message")
- case .customChat:
- Text("Custom Chat")
- case .promptToCode:
- Text("Prompt to Code")
- case .singleRoundDialog:
- Text("Single Round Dialog")
+ Group {
+ switch command.feature {
+ case .chatWithSelection:
+ Text("Send Message")
+ case .customChat:
+ Text("Custom Chat")
+ case .promptToCode:
+ Text("Modification")
+ case .singleRoundDialog:
+ Text("Single Round Dialog")
+ }
}
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .contentShape(Rectangle())
+ .onTapGesture {
+ store.send(.editCommand(command))
}
- .font(.caption)
- .foregroundStyle(.tertiary)
- }
- .frame(maxWidth: .infinity, alignment: .leading)
- .contentShape(Rectangle())
- .onTapGesture {
- store.send(.editCommand(command))
}
- }
- .padding(4)
- .background {
- WithViewStore(store, observe: { $0.editCustomCommand?.commandId }) { viewStore in
+ .padding(4)
+ .background {
RoundedRectangle(cornerRadius: 4)
.fill(
- viewStore.state == command.id
+ store.editCustomCommand?.commandId == command.id
? Color.primary.opacity(0.05)
: Color.clear
)
}
- }
- .contextMenu {
- Button("Remove") {
- store.send(.deleteCommand(command))
- }
+ .contextMenu {
+ Button("Remove") {
+ store.send(.deleteCommand(command))
+ }
- Button("Export") {
- store.send(.exportCommand(command))
+ Button("Export") {
+ store.send(.exportCommand(command))
+ }
}
}
}
}
- @ViewBuilder
- var rightPane: some View {
- IfLetStore(store.scope(
- state: \.editCustomCommand,
- action: CustomCommandFeature.Action.editCustomCommand
- )) { store in
- EditCustomCommandView(store: store)
- } else: {
- 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."
- )
- }
- SubSection(title: Text("Prompt to Code")) {
- 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."
- )
- }
- 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."
- )
+ struct RightPanel: View {
+ let store: StoreOf
+ var body: some View {
+ WithPerceptionTracking {
+ if let store = store.scope(
+ state: \.editCustomCommand,
+ action: \.editCustomCommand
+ ) {
+ EditCustomCommandView(store: store)
+ } else {
+ VStack {
+ SubSection(title: Text("Send Message")) {
+ Text(
+ "This command sends a message to the active chat tab. You can provide additional context as well. The additional context will be removed once a message is sent. If the message provided is empty, you can manually type the message in the chat."
+ )
+ }
+ SubSection(title: Text("Modification")) {
+ 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 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 `/shell`. For example, you can set the prompt to `/shell open .` to open the project in Finder."
+ )
+ }
+ }
+ .padding()
}
}
- .padding()
}
}
}
@@ -265,7 +275,9 @@ struct CustomCommandView_Preview: PreviewProvider {
extraSystemPrompt: nil,
prompt: "Hello",
useExtraSystemPrompt: false
- )
+ ),
+ ignoreExistingAttachments: false,
+ attachments: []
),
.init(
commandId: "2",
@@ -275,7 +287,9 @@ struct CustomCommandView_Preview: PreviewProvider {
prompt: "Refactor",
continuousMode: false,
generateDescription: true
- )
+ ),
+ ignoreExistingAttachments: false,
+ attachments: []
),
], "CustomCommandView_Preview"))
@@ -289,10 +303,12 @@ struct CustomCommandView_Preview: PreviewProvider {
extraSystemPrompt: nil,
prompt: "Hello",
useExtraSystemPrompt: false
- )
+ ),
+ ignoreExistingAttachments: false,
+ attachments: [] as [CustomCommand.Attachment]
)))
),
- reducer: CustomCommandFeature(settings: settings)
+ reducer: { CustomCommandFeature(settings: settings) }
),
settings: settings
)
@@ -309,7 +325,9 @@ struct CustomCommandView_NoEditing_Preview: PreviewProvider {
extraSystemPrompt: nil,
prompt: "Hello",
useExtraSystemPrompt: false
- )
+ ),
+ ignoreExistingAttachments: false,
+ attachments: []
),
.init(
commandId: "2",
@@ -319,7 +337,9 @@ struct CustomCommandView_NoEditing_Preview: PreviewProvider {
prompt: "Refactor",
continuousMode: false,
generateDescription: true
- )
+ ),
+ ignoreExistingAttachments: false,
+ attachments: []
),
], "CustomCommandView_Preview"))
@@ -328,7 +348,7 @@ struct CustomCommandView_NoEditing_Preview: PreviewProvider {
initialState: .init(
editCustomCommand: nil
),
- reducer: CustomCommandFeature(settings: settings)
+ reducer: { CustomCommandFeature(settings: settings) }
),
settings: settings
)
diff --git a/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommand.swift b/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommand.swift
index 03d8ddf9..e927a9ff 100644
--- a/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommand.swift
+++ b/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommand.swift
@@ -3,7 +3,8 @@ import Foundation
import Preferences
import SwiftUI
-struct EditCustomCommand: ReducerProtocol {
+@Reducer
+struct EditCustomCommand {
enum CommandType: Int, CaseIterable, Equatable {
case sendMessage
case promptToCode
@@ -11,9 +12,10 @@ struct EditCustomCommand: ReducerProtocol {
case singleRoundDialog
}
+ @ObservableState
struct State: Equatable {
- @BindingState var name: String = ""
- @BindingState var commandType: CommandType = .sendMessage
+ var name: String = ""
+ var commandType: CommandType = .sendMessage
var isNewCommand: Bool = false
let commandId: String
@@ -21,11 +23,16 @@ struct EditCustomCommand: ReducerProtocol {
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):
@@ -81,29 +88,34 @@ struct EditCustomCommand: ReducerProtocol {
case promptToCode(EditPromptToCodeCommand.Action)
case customChat(EditCustomChatCommand.Action)
case singleRoundDialog(EditSingleRoundDialogCommand.Action)
+ case attachments(EditCustomCommandAttachment.Action)
}
let settings: CustomCommandView.Settings
@Dependency(\.toast) var toast
- var body: some ReducerProtocol {
- Scope(state: \.sendMessage, action: /Action.sendMessage) {
+ var body: some ReducerOf {
+ Scope(state: \.sendMessage, action: \.sendMessage) {
EditSendMessageCommand()
}
- Scope(state: \.promptToCode, action: /Action.promptToCode) {
+ Scope(state: \.promptToCode, action: \.promptToCode) {
EditPromptToCodeCommand()
}
- Scope(state: \.customChat, action: /Action.customChat) {
+ Scope(state: \.customChat, action: \.customChat) {
EditCustomChatCommand()
}
- Scope(state: \.singleRoundDialog, action: /Action.singleRoundDialog) {
+ Scope(state: \.singleRoundDialog, action: \.singleRoundDialog) {
EditSingleRoundDialogCommand()
}
+ Scope(state: \.attachments, action: \.attachments) {
+ EditCustomCommandAttachment()
+ }
+
BindingReducer()
Reduce { state, action in
@@ -149,7 +161,9 @@ struct EditCustomCommand: ReducerProtocol {
receiveReplyInNotification: state.receiveReplyInNotification
)
}
- }()
+ }(),
+ ignoreExistingAttachments: state.attachments.ignoreExistingAttachments,
+ attachments: state.attachments.attachments
)
if state.isNewCommand {
@@ -182,23 +196,51 @@ struct EditCustomCommand: ReducerProtocol {
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
}
}
}
}
-struct EditSendMessageCommand: ReducerProtocol {
+@Reducer
+struct EditSendMessageCommand {
+ @ObservableState
struct State: Equatable {
- @BindingState var extraSystemPrompt: String = ""
- @BindingState var useExtraSystemPrompt: Bool = false
- @BindingState var prompt: String = ""
+ var extraSystemPrompt: String = ""
+ var useExtraSystemPrompt: Bool = false
+ var prompt: String = ""
}
enum Action: BindableAction, Equatable {
case binding(BindingAction)
}
- var body: some ReducerProtocol {
+ var body: some ReducerOf {
BindingReducer()
Reduce { _, action in
@@ -210,51 +252,57 @@ struct EditSendMessageCommand: ReducerProtocol {
}
}
-struct EditPromptToCodeCommand: ReducerProtocol {
+@Reducer
+struct EditPromptToCodeCommand {
+ @ObservableState
struct State: Equatable {
- @BindingState var extraSystemPrompt: String = ""
- @BindingState var prompt: String = ""
- @BindingState var continuousMode: Bool = false
- @BindingState var generateDescription: Bool = false
+ var extraSystemPrompt: String = ""
+ var prompt: String = ""
+ var continuousMode: Bool = false
+ var generateDescription: Bool = false
}
enum Action: BindableAction, Equatable {
case binding(BindingAction)
}
- var body: some ReducerProtocol {
+ var body: some ReducerOf {
BindingReducer()
}
}
-struct EditCustomChatCommand: ReducerProtocol {
+@Reducer
+struct EditCustomChatCommand {
+ @ObservableState
struct State: Equatable {
- @BindingState var systemPrompt: String = ""
- @BindingState var prompt: String = ""
+ var systemPrompt: String = ""
+ var prompt: String = ""
}
enum Action: BindableAction, Equatable {
case binding(BindingAction)
}
- var body: some ReducerProtocol {
+ var body: some ReducerOf {
BindingReducer()
}
}
-struct EditSingleRoundDialogCommand: ReducerProtocol {
+@Reducer
+struct EditSingleRoundDialogCommand {
+ @ObservableState
struct State: Equatable {
- @BindingState var systemPrompt: String = ""
- @BindingState var overwriteSystemPrompt: Bool = false
- @BindingState var prompt: String = ""
- @BindingState var receiveReplyInNotification: Bool = false
+ var systemPrompt: String = ""
+ var overwriteSystemPrompt: Bool = false
+ var prompt: String = ""
+ var receiveReplyInNotification: Bool = false
}
enum Action: BindableAction, Equatable {
case binding(BindingAction)
}
- var body: some ReducerProtocol {
+ var body: some ReducerOf {
BindingReducer()
}
}
diff --git a/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift b/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift
index 74b61585..e2304f8b 100644
--- a/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift
+++ b/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift
@@ -6,7 +6,7 @@ import SwiftUI
@MainActor
struct EditCustomCommandView: View {
@Environment(\.toast) var toast
- let store: StoreOf
+ @Perception.Bindable var store: StoreOf
init(store: StoreOf) {
self.store = store
@@ -24,10 +24,10 @@ struct EditCustomCommandView: View {
}
@ViewBuilder var sharedForm: some View {
- WithViewStore(store, observe: { $0 }) { viewStore in
- TextField("Name", text: viewStore.$name)
+ WithPerceptionTracking {
+ TextField("Name", text: $store.name)
- Picker("Command Type", selection: viewStore.$commandType) {
+ Picker("Command Type", selection: $store.commandType) {
ForEach(
EditCustomCommand.CommandType.allCases,
id: \.rawValue
@@ -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:
@@ -50,37 +50,42 @@ struct EditCustomCommandView: View {
}
@ViewBuilder var featureSpecificForm: some View {
- WithViewStore(
- store,
- observe: { $0.commandType }
- ) { viewStore in
- switch viewStore.state {
+ WithPerceptionTracking {
+ switch store.commandType {
case .sendMessage:
EditSendMessageCommandView(
store: store.scope(
state: \.sendMessage,
- action: EditCustomCommand.Action.sendMessage
+ action: \.sendMessage
+ ),
+ attachmentStore: store.scope(
+ state: \.attachments,
+ action: \.attachments
)
)
case .promptToCode:
EditPromptToCodeCommandView(
store: store.scope(
state: \.promptToCode,
- action: EditCustomCommand.Action.promptToCode
+ action: \.promptToCode
)
)
case .customChat:
EditCustomChatCommandView(
store: store.scope(
state: \.customChat,
- action: EditCustomCommand.Action.customChat
+ action: \.customChat
+ ),
+ attachmentStore: store.scope(
+ state: \.attachments,
+ action: \.attachments
)
)
case .singleRoundDialog:
EditSingleRoundDialogCommandView(
store: store.scope(
state: \.singleRoundDialog,
- action: EditCustomCommand.Action.singleRoundDialog
+ action: \.singleRoundDialog
)
)
}
@@ -88,23 +93,23 @@ struct EditCustomCommandView: View {
}
@ViewBuilder var bottomBar: some View {
- VStack {
- Divider()
+ WithPerceptionTracking {
+ VStack {
+ Divider()
- VStack(alignment: .trailing) {
- Text(
- "After renaming or adding a custom command, please restart Xcode to refresh the menu."
- )
- .foregroundStyle(.secondary)
+ 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)
- }
+ HStack {
+ Spacer()
+ Button("Close") {
+ store.send(.close)
+ }
- WithViewStore(store, observe: { $0.isNewCommand }) { viewStore in
- if viewStore.state {
+ if store.isNewCommand {
Button("Add") {
store.send(.saveCommand)
}
@@ -115,51 +120,196 @@ struct EditCustomCommandView: View {
}
}
}
+ .padding(.horizontal)
}
- .padding(.horizontal)
+ .padding(.bottom)
+ .background(.regularMaterial)
+ }
+ }
+}
+
+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"
}
- .padding(.bottom)
- .background(.regularMaterial)
}
}
struct EditSendMessageCommandView: View {
- let store: StoreOf
+ @Perception.Bindable var store: StoreOf
+ var attachmentStore: StoreOf
var body: some View {
- WithViewStore(store, observe: { $0 }) { viewStore in
+ WithPerceptionTracking {
VStack(alignment: .leading, spacing: 4) {
- Toggle("Extra System Prompt", isOn: viewStore.$useExtraSystemPrompt)
- EditableText(text: viewStore.$extraSystemPrompt)
+ Toggle("Extra Context", isOn: $store.useExtraSystemPrompt)
+ EditableText(text: $store.extraSystemPrompt)
}
.padding(.vertical, 4)
VStack(alignment: .leading, spacing: 4) {
- Text("Prompt")
- EditableText(text: viewStore.$prompt)
+ Text("Send immediately")
+ EditableText(text: $store.prompt)
}
.padding(.vertical, 4)
+
+ CustomCommandAttachmentPickerView(store: attachmentStore)
+ .padding(.vertical, 4)
}
}
}
struct EditPromptToCodeCommandView: View {
- let store: StoreOf
+ @Perception.Bindable var store: StoreOf
var body: some View {
- WithViewStore(store, observe: { $0 }) { viewStore in
- Toggle("Continuous Mode", isOn: viewStore.$continuousMode)
- Toggle("Generate Description", isOn: viewStore.$generateDescription)
+ WithPerceptionTracking {
+ Toggle("Continuous Mode", isOn: $store.continuousMode)
VStack(alignment: .leading, spacing: 4) {
Text("Extra Context")
- EditableText(text: viewStore.$extraSystemPrompt)
+ EditableText(text: $store.extraSystemPrompt)
}
.padding(.vertical, 4)
VStack(alignment: .leading, spacing: 4) {
- Text("Prompt")
- EditableText(text: viewStore.$prompt)
+ Text("Instruction")
+ EditableText(text: $store.prompt)
}
.padding(.vertical, 4)
}
@@ -167,39 +317,43 @@ struct EditPromptToCodeCommandView: View {
}
struct EditCustomChatCommandView: View {
- let store: StoreOf
+ @Perception.Bindable var store: StoreOf
+ var attachmentStore: StoreOf
var body: some View {
- WithViewStore(store, observe: { $0 }) { viewStore in
+ WithPerceptionTracking {
VStack(alignment: .leading, spacing: 4) {
- Text("System Prompt")
- EditableText(text: viewStore.$systemPrompt)
+ Text("Topic")
+ EditableText(text: $store.systemPrompt)
}
.padding(.vertical, 4)
VStack(alignment: .leading, spacing: 4) {
- Text("Prompt")
- EditableText(text: viewStore.$prompt)
+ Text("Send immediately")
+ EditableText(text: $store.prompt)
}
.padding(.vertical, 4)
+
+ CustomCommandAttachmentPickerView(store: attachmentStore)
+ .padding(.vertical, 4)
}
}
}
struct EditSingleRoundDialogCommandView: View {
- let store: StoreOf
+ @Perception.Bindable var store: StoreOf
var body: some View {
- WithViewStore(store, observe: { $0 }) { viewStore in
+ WithPerceptionTracking {
VStack(alignment: .leading, spacing: 4) {
Text("System Prompt")
- EditableText(text: viewStore.$systemPrompt)
+ EditableText(text: $store.systemPrompt)
}
.padding(.vertical, 4)
- Picker(selection: viewStore.$overwriteSystemPrompt) {
- Text("Append to Default System Prompt").tag(false)
- Text("Overwrite Default System Prompt").tag(true)
+ Picker(selection: $store.overwriteSystemPrompt) {
+ Text("Append to default system prompt").tag(false)
+ Text("Overwrite default system prompt").tag(true)
} label: {
Text("Mode")
}
@@ -207,11 +361,11 @@ struct EditSingleRoundDialogCommandView: View {
VStack(alignment: .leading, spacing: 4) {
Text("Prompt")
- EditableText(text: viewStore.$prompt)
+ EditableText(text: $store.prompt)
}
.padding(.vertical, 4)
- Toggle("Receive Reply in Notification", isOn: viewStore.$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."
)
@@ -221,8 +375,6 @@ struct EditSingleRoundDialogCommandView: View {
}
}
-
-
// MARK: - Preview
struct EditCustomCommandView_Preview: PreviewProvider {
@@ -237,14 +389,18 @@ struct EditCustomCommandView_Preview: PreviewProvider {
prompt: "Hello",
continuousMode: false,
generateDescription: true
- )
+ ),
+ ignoreExistingAttachments: false,
+ attachments: [] as [CustomCommand.Attachment]
)),
- reducer: EditCustomCommand(
- settings: .init(customCommands: .init(
- wrappedValue: [],
- "CustomCommandView_Preview"
- ))
- )
+ reducer: {
+ EditCustomCommand(
+ settings: .init(customCommands: .init(
+ wrappedValue: [],
+ "CustomCommandView_Preview"
+ ))
+ )
+ }
)
)
.frame(width: 800)
@@ -255,7 +411,7 @@ struct EditSingleRoundDialogCommandView_Preview: PreviewProvider {
static var previews: some View {
EditSingleRoundDialogCommandView(store: .init(
initialState: .init(),
- reducer: EditSingleRoundDialogCommand()
+ reducer: { EditSingleRoundDialogCommand() }
))
.frame(width: 800, height: 600)
}
diff --git a/Core/Sources/HostApp/DebugView.swift b/Core/Sources/HostApp/DebugView.swift
index 1f210e96..527d5b7b 100644
--- a/Core/Sources/HostApp/DebugView.swift
+++ b/Core/Sources/HostApp/DebugView.swift
@@ -29,11 +29,14 @@ final class DebugSettings: ObservableObject {
var observeToAXNotificationWithDefaultMode
@AppStorage(\.useCloudflareDomainNameForLicenseCheck)
var useCloudflareDomainNameForLicenseCheck
+ @AppStorage(\.doNotInstallLaunchAgentAutomatically)
+ var doNotInstallLaunchAgentAutomatically
init() {}
}
struct DebugSettingsView: View {
@StateObject var settings = DebugSettings()
+ @Environment(\.updateChecker) var updateChecker
var body: some View {
ScrollView {
@@ -134,6 +137,16 @@ 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()
+ }
}
}
.frame(maxWidth: .infinity)
diff --git a/Core/Sources/HostApp/FeatureSettings/Chat/ChatSettingsGeneralSectionView.swift b/Core/Sources/HostApp/FeatureSettings/Chat/ChatSettingsGeneralSectionView.swift
new file mode 100644
index 00000000..dfb60355
--- /dev/null
+++ b/Core/Sources/HostApp/FeatureSettings/Chat/ChatSettingsGeneralSectionView.swift
@@ -0,0 +1,383 @@
+import Client
+import Preferences
+import SharedUIComponents
+import SwiftUI
+import XPCShared
+
+#if canImport(ProHostApp)
+import ProHostApp
+#endif
+
+struct ChatSettingsGeneralSectionView: View {
+ class Settings: ObservableObject {
+ static let availableLocalizedLocales = Locale.availableLocalizedLocales
+ @AppStorage(\.chatGPTLanguage) var chatGPTLanguage
+ @AppStorage(\.chatGPTTemperature) var chatGPTTemperature
+ @AppStorage(\.chatGPTMaxMessageCount) var chatGPTMaxMessageCount
+ @AppStorage(\.chatFontSize) var chatFontSize
+ @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
+ @AppStorage(
+ \.disableFloatOnTopWhenTheChatPanelIsDetached
+ ) var disableFloatOnTopWhenTheChatPanelIsDetached
+ @AppStorage(\.openChatMode) var openChatMode
+ @AppStorage(\.openChatInBrowserURL) var openChatInBrowserURL
+ @AppStorage(\.openChatInBrowserInInAppBrowser) var openChatInBrowserInInAppBrowser
+
+ 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
+ @Environment(\.toast) var toast
+ @StateObject var settings = Settings()
+ @State var maxTokenOverLimit = false
+
+ var body: some View {
+ VStack {
+ openChatSettingsForm
+ SettingsDivider("Conversation")
+ chatSettingsForm
+ SettingsDivider("UI")
+ uiForm
+ SettingsDivider("Plugin")
+ pluginForm
+ }
+ }
+
+ @ViewBuilder
+ var openChatSettingsForm: some View {
+ Form {
+ Picker(
+ "Open Chat Mode",
+ selection: .init(get: {
+ settings.openChatMode.value
+ }, set: {
+ settings.openChatMode = .init($0)
+ })
+ ) {
+ Text("Open chat panel").tag(OpenChatMode.chatPanel)
+ Text("Open web page in browser").tag(OpenChatMode.browser)
+ ForEach(settings.openChatOptions) { mode in
+ switch 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.value == .browser {
+ TextField(
+ "Chat web page URL",
+ text: $settings.openChatInBrowserURL,
+ prompt: Text("https://")
+ )
+ .textFieldStyle(.roundedBorder)
+ .disableAutocorrection(true)
+ .autocorrectionDisabled(true)
+
+ #if canImport(ProHostApp)
+ WithFeatureEnabled(\.browserTab) {
+ Toggle(
+ "Open web page in chat panel",
+ isOn: $settings.openChatInBrowserInInAppBrowser
+ )
+ }
+ #endif
+ }
+ }
+ }
+
+ @ViewBuilder
+ var chatSettingsForm: some View {
+ Form {
+ Picker(
+ "Chat model",
+ selection: $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"
+ )
+ .tag(settings.utilityChatModelId)
+ }
+
+ ForEach(settings.chatModels, id: \.id) { chatModel in
+ Text(chatModel.name).tag(chatModel.id)
+ }
+ }
+
+ Picker(
+ "Embedding model",
+ selection: $settings.defaultChatFeatureEmbeddingModelId
+ ) {
+ if !settings.embeddingModels
+ .contains(where: { $0.id == settings.defaultChatFeatureEmbeddingModelId })
+ {
+ Text(
+ (settings.embeddingModels.first?.name).map { "\($0) (Default)" }
+ ?? "No model found"
+ )
+ .tag(settings.defaultChatFeatureEmbeddingModelId)
+ }
+
+ ForEach(settings.embeddingModels, id: \.id) { embeddingModel in
+ Text(embeddingModel.name).tag(embeddingModel.id)
+ }
+ }
+
+ if #available(macOS 13.0, *) {
+ LabeledContent("Reply in language") {
+ languagePicker
+ }
+ } else {
+ HStack {
+ Text("Reply in language")
+ languagePicker
+ }
+ }
+
+ HStack {
+ Slider(value: $settings.chatGPTTemperature, in: 0...2, step: 0.1) {
+ Text("Temperature")
+ }
+
+ Text(
+ "\(settings.chatGPTTemperature.formatted(.number.precision(.fractionLength(1))))"
+ )
+ .font(.body)
+ .foregroundColor(settings.chatGPTTemperature >= 1 ? .red : .secondary)
+ .monospacedDigit()
+ .padding(.vertical, 2)
+ .padding(.horizontal, 6)
+ .background(
+ RoundedRectangle(cornerRadius: 4, style: .continuous)
+ .fill(Color.primary.opacity(0.1))
+ )
+ }
+
+ Picker(
+ "Memory",
+ selection: $settings.chatGPTMaxMessageCount
+ ) {
+ Text("No Limit").tag(0)
+ Text("3 Messages").tag(3)
+ Text("5 Messages").tag(5)
+ 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("Additional system prompt")
+ EditableText(text: $settings.defaultChatSystemPrompt)
+ .lineLimit(6)
+ }
+ .padding(.vertical, 4)
+ }
+ }
+
+ @ViewBuilder
+ var uiForm: some View {
+ Form {
+ HStack {
+ TextField(text: .init(get: {
+ "\(Int(settings.chatFontSize))"
+ }, set: {
+ settings.chatFontSize = Double(Int($0) ?? 0)
+ })) {
+ Text("Font size of message")
+ }
+ .textFieldStyle(.roundedBorder)
+
+ Text("pt")
+ }
+
+ FontPicker(font: $settings.chatCodeFont) {
+ Text("Font of code")
+ }
+
+ Toggle(isOn: $settings.wrapCodeInCodeBlock) {
+ Text("Wrap text in code block")
+ }
+
+ CodeHighlightThemePicker(scenario: .chat)
+
+ Picker("Always-on-top behavior", selection: $settings.chatPanelFloatOnTopOption) {
+ Text("Always").tag(UserDefaultPreferenceKeys.ChatPanelFloatOnTopOption.alwaysOnTop)
+ Text("When Xcode is active")
+ .tag(UserDefaultPreferenceKeys.ChatPanelFloatOnTopOption.onTopWhenXcodeIsActive)
+ Text("Never").tag(UserDefaultPreferenceKeys.ChatPanelFloatOnTopOption.never)
+ }
+
+ Toggle(isOn: $settings.disableFloatOnTopWhenTheChatPanelIsDetached) {
+ Text("Disable always-on-top when the chat panel is detached")
+ }.disabled(settings.chatPanelFloatOnTopOption == .never)
+
+ Toggle(isOn: $settings.keepFloatOnTopIfChatPanelAndXcodeOverlaps) {
+ Text("Keep always-on-top if the chat panel and Xcode overlaps and Xcode is active")
+ }
+ .disabled(
+ !settings.disableFloatOnTopWhenTheChatPanelIsDetached
+ || settings.chatPanelFloatOnTopOption == .never
+ )
+ }
+ }
+
+ @ViewBuilder
+ var pluginForm: some View {
+ Form {
+ TextField(text: .init(get: {
+ "\(Int(settings.chatSearchPluginMaxIterations))"
+ }, set: {
+ settings.chatSearchPluginMaxIterations = Int($0) ?? 0
+ })) {
+ Text("Search plugin max iterations")
+ }
+ .textFieldStyle(.roundedBorder)
+ }
+ }
+
+ var languagePicker: some View {
+ Menu {
+ if !settings.chatGPTLanguage.isEmpty,
+ !Settings.availableLocalizedLocales
+ .contains(settings.chatGPTLanguage)
+ {
+ Button(
+ settings.chatGPTLanguage,
+ action: { self.settings.chatGPTLanguage = settings.chatGPTLanguage }
+ )
+ }
+ Button(
+ "Auto-detected by LLM",
+ action: { self.settings.chatGPTLanguage = "" }
+ )
+ ForEach(
+ Settings.availableLocalizedLocales,
+ id: \.self
+ ) { localizedLocales in
+ Button(
+ localizedLocales,
+ action: { self.settings.chatGPTLanguage = localizedLocales }
+ )
+ }
+ } label: {
+ Text(
+ settings.chatGPTLanguage.isEmpty
+ ? "Auto-detected by LLM"
+ : settings.chatGPTLanguage
+ )
+ }
+ }
+}
+
+// MARK: - Preview
+
+//
+// #Preview {
+// ScrollView {
+// ChatSettingsView()
+// .padding()
+// }
+// .frame(height: 800)
+// .environment(\.overrideFeatureFlag, \.never)
+// }
+//
+
diff --git a/Core/Sources/HostApp/FeatureSettings/Chat/ChatSettingsView.swift b/Core/Sources/HostApp/FeatureSettings/Chat/ChatSettingsView.swift
new file mode 100644
index 00000000..8540b9d2
--- /dev/null
+++ b/Core/Sources/HostApp/FeatureSettings/Chat/ChatSettingsView.swift
@@ -0,0 +1,38 @@
+import Preferences
+import SharedUIComponents
+import SwiftUI
+
+struct ChatSettingsView: View {
+ enum Tab {
+ case general
+ }
+
+ @State var tabSelection: Tab = .general
+
+ var body: some View {
+ VStack(spacing: 0) {
+ Picker("", selection: $tabSelection) {
+ Text("General").tag(Tab.general)
+ }
+ .pickerStyle(.segmented)
+ .padding(8)
+
+ Divider()
+ .shadow(radius: 10)
+
+ ScrollView {
+ Group {
+ switch tabSelection {
+ case .general:
+ ChatSettingsGeneralSectionView()
+ }
+ }.padding()
+ }
+ }
+ }
+}
+
+#Preview {
+ ChatSettingsView()
+ .frame(width: 600, height: 500)
+}
diff --git a/Core/Sources/HostApp/FeatureSettings/ChatSettingsView.swift b/Core/Sources/HostApp/FeatureSettings/ChatSettingsView.swift
deleted file mode 100644
index 7683ebf2..00000000
--- a/Core/Sources/HostApp/FeatureSettings/ChatSettingsView.swift
+++ /dev/null
@@ -1,439 +0,0 @@
-import Preferences
-import SharedUIComponents
-import SwiftUI
-
-#if canImport(ProHostApp)
-import ProHostApp
-#endif
-
-struct ChatSettingsView: View {
- class Settings: ObservableObject {
- static let availableLocalizedLocales = Locale.availableLocalizedLocales
- @AppStorage(\.chatGPTLanguage) var chatGPTLanguage
- @AppStorage(\.chatGPTTemperature) var chatGPTTemperature
- @AppStorage(\.chatGPTMaxMessageCount) var chatGPTMaxMessageCount
- @AppStorage(\.chatFontSize) var chatFontSize
- @AppStorage(\.chatCodeFont) var chatCodeFont
-
- @AppStorage(\.defaultChatFeatureChatModelId) var defaultChatFeatureChatModelId
- @AppStorage(\.defaultChatSystemPrompt) var defaultChatSystemPrompt
- @AppStorage(\.chatSearchPluginMaxIterations) var chatSearchPluginMaxIterations
- @AppStorage(\.defaultChatFeatureEmbeddingModelId) var defaultChatFeatureEmbeddingModelId
- @AppStorage(\.chatModels) var chatModels
- @AppStorage(\.embeddingModels) var embeddingModels
- @AppStorage(\.wrapCodeInChatCodeBlock) var wrapCodeInCodeBlock
-
- init() {}
- }
-
- @Environment(\.openURL) var openURL
- @Environment(\.toast) var toast
- @StateObject var settings = Settings()
- @State var maxTokenOverLimit = false
-
- var body: some View {
- VStack {
- chatSettingsForm
- SettingsDivider("UI")
- uiForm
- SettingsDivider("Plugin")
- pluginForm
- ScopeForm()
- }
- }
-
- @ViewBuilder
- var chatSettingsForm: some View {
- Form {
- Picker(
- "Chat Model",
- selection: $settings.defaultChatFeatureChatModelId
- ) {
- if !settings.chatModels
- .contains(where: { $0.id == settings.defaultChatFeatureChatModelId })
- {
- Text(
- (settings.chatModels.first?.name).map { "\($0) (Default)" }
- ?? "No Model Found"
- )
- .tag(settings.defaultChatFeatureChatModelId)
- }
-
- ForEach(settings.chatModels, id: \.id) { chatModel in
- Text(chatModel.name).tag(chatModel.id)
- }
- }
-
- Picker(
- "Embedding Model",
- selection: $settings.defaultChatFeatureEmbeddingModelId
- ) {
- if !settings.embeddingModels
- .contains(where: { $0.id == settings.defaultChatFeatureEmbeddingModelId })
- {
- Text(
- (settings.embeddingModels.first?.name).map { "\($0) (Default)" }
- ?? "No Model Found"
- )
- .tag(settings.defaultChatFeatureEmbeddingModelId)
- }
-
- ForEach(settings.embeddingModels, id: \.id) { embeddingModel in
- Text(embeddingModel.name).tag(embeddingModel.id)
- }
- }
-
- if #available(macOS 13.0, *) {
- LabeledContent("Reply in Language") {
- languagePicker
- }
- } else {
- HStack {
- Text("Reply in Language")
- languagePicker
- }
- }
-
- HStack {
- Slider(value: $settings.chatGPTTemperature, in: 0...2, step: 0.1) {
- Text("Temperature")
- }
-
- Text(
- "\(settings.chatGPTTemperature.formatted(.number.precision(.fractionLength(1))))"
- )
- .font(.body)
- .foregroundColor(settings.chatGPTTemperature >= 1 ? .red : .secondary)
- .monospacedDigit()
- .padding(.vertical, 2)
- .padding(.horizontal, 6)
- .background(
- RoundedRectangle(cornerRadius: 4, style: .continuous)
- .fill(Color.primary.opacity(0.1))
- )
- }
-
- Picker(
- "Memory",
- selection: $settings.chatGPTMaxMessageCount
- ) {
- Text("No Limit").tag(0)
- Text("3 Messages").tag(3)
- Text("5 Messages").tag(5)
- Text("7 Messages").tag(7)
- Text("9 Messages").tag(9)
- Text("11 Messages").tag(11)
- }
-
- VStack(alignment: .leading, spacing: 4) {
- Text("Default System Prompt")
- EditableText(text: $settings.defaultChatSystemPrompt)
- .lineLimit(6)
- }
- .padding(.vertical, 4)
- }
- }
-
- @ViewBuilder
- var uiForm: some View {
- Form {
- HStack {
- TextField(text: .init(get: {
- "\(Int(settings.chatFontSize))"
- }, set: {
- settings.chatFontSize = Double(Int($0) ?? 0)
- })) {
- Text("Font size of message")
- }
- .textFieldStyle(.roundedBorder)
-
- Text("pt")
- }
-
- FontPicker(font: $settings.chatCodeFont) {
- Text("Font of code")
- }
-
- Toggle(isOn: $settings.wrapCodeInCodeBlock) {
- Text("Wrap code in code block")
- }
-
- #if canImport(ProHostApp)
-
- CodeHighlightThemePicker(scenario: .chat)
-
- #endif
- }
- }
-
- @ViewBuilder
- var pluginForm: some View {
- Form {
- TextField(text: .init(get: {
- "\(Int(settings.chatSearchPluginMaxIterations))"
- }, set: {
- settings.chatSearchPluginMaxIterations = Int($0) ?? 0
- })) {
- Text("Search Plugin Max Iterations")
- }
- .textFieldStyle(.roundedBorder)
- }
- }
-
- var languagePicker: some View {
- Menu {
- if !settings.chatGPTLanguage.isEmpty,
- !Settings.availableLocalizedLocales
- .contains(settings.chatGPTLanguage)
- {
- Button(
- settings.chatGPTLanguage,
- action: { self.settings.chatGPTLanguage = settings.chatGPTLanguage }
- )
- }
- Button(
- "Auto-detected by ChatGPT",
- action: { self.settings.chatGPTLanguage = "" }
- )
- ForEach(
- Settings.availableLocalizedLocales,
- id: \.self
- ) { localizedLocales in
- Button(
- localizedLocales,
- action: { self.settings.chatGPTLanguage = localizedLocales }
- )
- }
- } label: {
- Text(
- settings.chatGPTLanguage.isEmpty
- ? "Auto-detected by ChatGPT"
- : settings.chatGPTLanguage
- )
- }
- }
-
- struct ScopeForm: 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 {
- SettingsDivider("Scopes")
-
- 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.
- """)
- } 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.")
- }
- }
- }
- ) {
- 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)
- }
- }
- }
- }
- }
- }
- }
-}
-
-// MARK: - Preview
-
-//
-// #Preview {
-// ScrollView {
-// ChatSettingsView()
-// .padding()
-// }
-// .frame(height: 800)
-// .environment(\.overrideFeatureFlag, \.never)
-// }
-//
-
diff --git a/Core/Sources/HostApp/FeatureSettings/PromptToCodeSettingsView.swift b/Core/Sources/HostApp/FeatureSettings/PromptToCodeSettingsView.swift
index 630a9bba..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)
@@ -20,7 +16,7 @@ struct PromptToCodeSettingsView: View {
var promptToCodeChatModelId
@AppStorage(\.promptToCodeEmbeddingModelId)
var promptToCodeEmbeddingModelId
-
+ @AppStorage(\.wrapCodeInPromptToCode) var wrapCode
@AppStorage(\.chatModels) var chatModels
@AppStorage(\.embeddingModels) var embeddingModels
init() {}
@@ -32,10 +28,10 @@ struct PromptToCodeSettingsView: View {
VStack(alignment: .center) {
Form {
Picker(
- "Chat Model",
+ "Chat model",
selection: $settings.promptToCodeChatModelId
) {
- Text("Same as Chat Feature").tag("")
+ Text("Same as chat feature").tag("")
if !settings.chatModels
.contains(where: { $0.id == settings.promptToCodeChatModelId }),
@@ -43,7 +39,7 @@ struct PromptToCodeSettingsView: View {
{
Text(
(settings.chatModels.first?.name).map { "\($0) (Default)" }
- ?? "No Model Found"
+ ?? "No model found"
)
.tag(settings.promptToCodeChatModelId)
}
@@ -52,104 +48,25 @@ 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")
Form {
Toggle(isOn: $settings.hideCommonPrecedingSpaces) {
- Text("Hide Common Preceding Spaces")
+ Text("Hide common preceding spaces")
}
- #if canImport(ProHostApp)
+ Toggle(isOn: $settings.wrapCode) {
+ Text("Wrap code")
+ }
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/SuggestionFeatureDisabledLanguageListView.swift b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureDisabledLanguageListView.swift
similarity index 89%
rename from Core/Sources/HostApp/FeatureSettings/SuggestionFeatureDisabledLanguageListView.swift
rename to Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureDisabledLanguageListView.swift
index e011751b..6d894cfd 100644
--- a/Core/Sources/HostApp/FeatureSettings/SuggestionFeatureDisabledLanguageListView.swift
+++ b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureDisabledLanguageListView.swift
@@ -1,4 +1,4 @@
-import SuggestionModel
+import SuggestionBasic
import SwiftUI
import SharedUIComponents
@@ -29,16 +29,8 @@ struct SuggestionFeatureDisabledLanguageListView: View {
.padding()
}
.buttonStyle(.plain)
- Text("Enabled Projects")
+ Text("Disabled Languages")
Spacer()
- Button(action: {
- isAddingNewProject = true
- }) {
- Image(systemName: "plus.circle.fill")
- .foregroundStyle(.secondary)
- .padding()
- }
- .buttonStyle(.plain)
}
.background(Color(nsColor: .separatorColor))
@@ -82,9 +74,10 @@ 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/SuggestionFeatureEnabledProjectListView.swift b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureEnabledProjectListView.swift
similarity index 98%
rename from Core/Sources/HostApp/FeatureSettings/SuggestionFeatureEnabledProjectListView.swift
rename to Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureEnabledProjectListView.swift
index f57cd5e4..0cf66ca6 100644
--- a/Core/Sources/HostApp/FeatureSettings/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/SuggestionSettingsView.swift b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsGeneralSectionView.swift
similarity index 68%
rename from Core/Sources/HostApp/FeatureSettings/SuggestionSettingsView.swift
rename to Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsGeneralSectionView.swift
index 02c31e54..390c7f98 100644
--- a/Core/Sources/HostApp/FeatureSettings/SuggestionSettingsView.swift
+++ b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsGeneralSectionView.swift
@@ -8,7 +8,7 @@ import XPCShared
import ProHostApp
#endif
-struct SuggestionSettingsView: View {
+struct SuggestionSettingsGeneralSectionView: View {
struct SuggestionFeatureProviderOption: Identifiable, Hashable {
var id: String {
(builtInProvider?.rawValue).map(String.init) ?? bundleIdentifier ?? "n/A"
@@ -56,8 +56,6 @@ struct SuggestionSettingsView: View {
var acceptSuggestionWithTab
@AppStorage(\.dismissSuggestionWithEsc)
var dismissSuggestionWithEsc
- @AppStorage(\.isSuggestionSenseEnabled)
- var isSuggestionSenseEnabled
var refreshExtensionSuggestionFeatureProvidersTask: Task?
@@ -70,7 +68,7 @@ struct SuggestionSettingsView: 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 }
@@ -90,8 +88,6 @@ struct SuggestionSettingsView: View {
extensionSuggestionFeatureProviderOptions = services.map {
.init(name: $0.name, bundleIdentifier: $0.bundleIdentifier)
}
- print(services.map(\.bundleIdentifier))
- print(suggestionFeatureProvider)
}
}
}
@@ -99,6 +95,7 @@ struct SuggestionSettingsView: View {
@StateObject var settings = Settings()
@State var isSuggestionFeatureEnabledListPickerOpen = false
@State var isSuggestionFeatureDisabledLanguageListViewOpen = false
+ @State var isTabToAcceptSuggestionModifierViewOpen = false
var body: some View {
Form {
@@ -106,9 +103,9 @@ struct SuggestionSettingsView: View {
ForEach(PresentationMode.allCases, id: \.rawValue) {
switch $0 {
case .nearbyTextCursor:
- Text("Nearby Text Cursor").tag($0)
+ Text("Nearby text cursor").tag($0)
case .floatingWidget:
- Text("Floating Widget").tag($0)
+ Text("Floating widget").tag($0)
}
}
} label: {
@@ -157,7 +154,7 @@ struct SuggestionSettingsView: View {
if !settings.extensionSuggestionFeatureProviderOptions.contains(where: {
$0.bundleIdentifier == identifier
}) {
- Text("\(name) (Not Found)").tag(
+ Text("\(name) (Not found)").tag(
SuggestionFeatureProviderOption(
name: name,
bundleIdentifier: identifier
@@ -166,39 +163,38 @@ struct SuggestionSettingsView: View {
}
}
} label: {
- Text("Feature Provider")
+ Text("Feature provider")
}
Toggle(isOn: $settings.realtimeSuggestionToggle) {
- Text("Real-time Suggestion")
+ Text("Real-time suggestion")
}
- #if canImport(ProHostApp)
- WithFeatureEnabled(\.suggestionSense) {
- Toggle(isOn: $settings.isSuggestionSenseEnabled) {
- Text("Suggestion Cheatsheet (Experimental)")
- }
- }
- #endif
+ Toggle(isOn: $settings.acceptSuggestionWithTab) {
+ HStack {
+ Text("Accept suggestion with Tab")
- #if canImport(ProHostApp)
- WithFeatureEnabled(\.tabToAcceptSuggestion) {
- Toggle(isOn: $settings.acceptSuggestionWithTab) {
- 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")
+ Text("Dismiss suggestion with ESC")
}
- #endif
HStack {
Toggle(isOn: $settings.disableSuggestionFeatureGlobally) {
- Text("Disable Suggestion Feature Globally")
+ Text("Disable suggestion feature globally")
}
- Button("Exception List") {
+ Button("Exception list") {
isSuggestionFeatureEnabledListPickerOpen = true
}
}.sheet(isPresented: $isSuggestionFeatureEnabledListPickerOpen) {
@@ -208,7 +204,7 @@ struct SuggestionSettingsView: View {
}
HStack {
- Button("Disabled Language List") {
+ Button("Disabled language list") {
isSuggestionFeatureDisabledLanguageListViewOpen = true
}
}.sheet(isPresented: $isSuggestionFeatureDisabledLanguageListViewOpen) {
@@ -219,7 +215,7 @@ struct SuggestionSettingsView: View {
HStack {
Slider(value: $settings.realtimeSuggestionDebounce, in: 0.1...2, step: 0.1) {
- Text("Real-time Suggestion Debounce")
+ Text("Real-time suggestion debounce")
}
Text(
@@ -240,29 +236,92 @@ struct SuggestionSettingsView: View {
Form {
Toggle(isOn: $settings.suggestionDisplayCompactMode) {
- Text("Hide Buttons")
+ Text("Hide buttons")
}
Toggle(isOn: $settings.hideCommonPrecedingSpacesInSuggestion) {
- Text("Hide Common Preceding Spaces")
+ Text("Hide common preceding spaces")
}
- #if canImport(ProHostApp)
-
CodeHighlightThemePicker(scenario: .suggestion)
- #endif
-
FontPicker(font: $settings.font) {
Text("Font")
}
}
}
-}
-struct SuggestionSettingsView_Previews: PreviewProvider {
- static var previews: some View {
- SuggestionSettingsView()
+ 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 {
+ SuggestionSettingsGeneralSectionView()
+ .padding()
+}
+
+#Preview {
+ SuggestionSettingsGeneralSectionView.TabToAcceptSuggestionModifierView()
+}
+
diff --git a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsView.swift b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsView.swift
new file mode 100644
index 00000000..632769a4
--- /dev/null
+++ b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsView.swift
@@ -0,0 +1,53 @@
+import Client
+import Preferences
+import SharedUIComponents
+import SwiftUI
+import XPCShared
+
+struct SuggestionSettingsView: View {
+ var tabContainer: ExternalTabContainer {
+ ExternalTabContainer.tabContainer(for: "SuggestionSettings")
+ }
+
+ enum Tab: Hashable {
+ case general
+ case other(String)
+ }
+
+ @State var tabSelection: Tab = .general
+
+ var body: some View {
+ VStack(spacing: 0) {
+ Picker("", selection: $tabSelection) {
+ Text("General").tag(Tab.general)
+ ForEach(tabContainer.tabs, id: \.id) { tab in
+ Text(tab.title).tag(Tab.other(tab.id))
+ }
+ }
+ .pickerStyle(.segmented)
+ .padding(8)
+
+ Divider()
+ .shadow(radius: 10)
+
+ ScrollView {
+ Group {
+ switch tabSelection {
+ case .general:
+ SuggestionSettingsGeneralSectionView()
+ case let .other(id):
+ tabContainer.tabView(for: id)
+ }
+ }.padding()
+ }
+ }
+ }
+}
+
+struct SuggestionSettingsView_Previews: PreviewProvider {
+ static var previews: some View {
+ SuggestionSettingsView()
+ .frame(width: 600, height: 500)
+ }
+}
+
diff --git a/Core/Sources/HostApp/FeatureSettingsView.swift b/Core/Sources/HostApp/FeatureSettingsView.swift
index cd5683f7..e8c1e38f 100644
--- a/Core/Sources/HostApp/FeatureSettingsView.swift
+++ b/Core/Sources/HostApp/FeatureSettingsView.swift
@@ -1,40 +1,41 @@
import SwiftUI
+import SharedUIComponents
struct FeatureSettingsView: View {
+ var tabContainer: ExternalTabContainer {
+ ExternalTabContainer.tabContainer(for: "Features")
+ }
+
@State var tag = 0
var body: some View {
- SidebarTabView(tag: $tag) {
- ScrollView {
- SuggestionSettingsView().padding()
- }
- .sidebarItem(
- tag: 0,
- title: "Suggestion",
- subtitle: "Generate suggestions for your code",
- image: "lightbulb"
- )
+ SidebarTabView(tag: $tag) {
+ SuggestionSettingsView()
+ .sidebarItem(
+ tag: 0,
+ title: "Suggestion",
+ subtitle: "Generate suggestions for your code",
+ image: "lightbulb"
+ )
- ScrollView {
- ChatSettingsView().padding()
- }
- .sidebarItem(
- tag: 1,
- title: "Chat",
- subtitle: "Chat about your code",
- image: "character.bubble"
- )
+ ChatSettingsView()
+ .sidebarItem(
+ tag: 1,
+ title: "Chat",
+ subtitle: "Chat about your code",
+ image: "character.bubble"
+ )
ScrollView {
PromptToCodeSettingsView().padding()
}
.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"
)
-
+
ScrollView {
XcodeSettingsView().padding()
}
@@ -42,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
+ )
+ }
}
}
}
@@ -66,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 a0c1ea88..96ade16c 100644
--- a/Core/Sources/HostApp/General.swift
+++ b/Core/Sources/HostApp/General.swift
@@ -5,49 +5,203 @@ import LaunchAgentManager
import SwiftUI
import XPCShared
-struct General: ReducerProtocol {
+@Reducer
+struct General {
+ @ObservableState
struct State: Equatable {
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
- var body: some ReducerProtocol {
+ 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()
@@ -65,18 +219,31 @@ struct General: ReducerProtocol {
return .run { send in
let service = try getService()
do {
- let xpcServiceVersion = try await service.getXPCServiceVersion().version
- let isAccessibilityPermissionGranted = try await service
- .getXPCServiceAccessibilityPermission()
- await send(.finishReloading(
- xpcServiceVersion: xpcServiceVersion,
- permissionGranted: isAccessibilityPermissionGranted
- ))
+ let isCommunicationReady = try await service.launchIfNeeded()
+ if isCommunicationReady {
+ let xpcServiceVersion = try await service.getXPCServiceVersion().version
+ let isAccessibilityPermissionGranted = try await service
+ .getXPCServiceAccessibilityPermission()
+ await send(.finishReloading(
+ xpcServiceVersion: xpcServiceVersion,
+ permissionGranted: isAccessibilityPermissionGranted
+ ))
+ } else {
+ toast("Launching service app.", .info)
+ try await Task.sleep(nanoseconds: 5_000_000_000)
+ await send(.reloadStatus)
+ }
+ } catch let error as XPCCommunicationBridgeError {
+ toast(
+ "Failed to reach communication bridge. \(error.localizedDescription)",
+ .error
+ )
+ await send(.failedReloading)
} catch {
toast(error.localizedDescription, .error)
await send(.failedReloading)
}
- }
+ }.cancellable(id: ReloadStatusCancellableId(), cancelInFlight: true)
case let .finishReloading(version, granted):
state.xpcServiceVersion = version
@@ -87,6 +254,38 @@ struct General: ReducerProtocol {
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 f25a7fb8..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,120 +30,121 @@ 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()
-
- Button(action: {
- store.send(.openExtensionManager)
- }) {
- HStack(spacing: 2) {
- Image(systemName: "puzzlepiece.extension.fill")
- Text("Extensions")
+ 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)
+
+ 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))
+ }
}
}
struct ExtensionServiceView: View {
- let store: StoreOf
+ @Perception.Bindable var store: StoreOf
var body: some View {
- VStack(alignment: .leading) {
- WithViewStore(store, observe: { $0.xpcServiceVersion }) { viewStore in
- Text("Extension Service Version: \(viewStore.state ?? "Loading..")")
- }
+ WithPerceptionTracking {
+ VStack(alignment: .leading) {
+ Text("Extension Service Version: \(store.xpcServiceVersion ?? "Loading..")")
- WithViewStore(store, observe: { $0.isAccessibilityPermissionGranted }) { viewStore in
let grantedStatus: String = {
- guard let granted = viewStore.state else { return "Loading.." }
+ guard let granted = store.isAccessibilityPermissionGranted
+ else { return "Loading.." }
return granted ? "Granted" : "Not Granted"
}()
Text("Accessibility Permission: \(grantedStatus)")
- }
- HStack {
- WithViewStore(store, observe: { $0.isReloading }) { viewStore in
- Button(action: { viewStore.send(.reloadStatus) }) {
+ HStack {
+ Button(action: { store.send(.reloadStatus) }) {
Text("Refresh")
- }.disabled(viewStore.state)
- }
-
- Button(action: {
- Task {
- let workspace = NSWorkspace.shared
- let url = Bundle.main.bundleURL
- .appendingPathComponent("Contents")
- .appendingPathComponent("Applications")
- .appendingPathComponent("CopilotForXcodeExtensionService.app")
- workspace.activateFileViewerSelecting([url])
+ }.disabled(store.isReloading)
+
+ Button(action: {
+ Task {
+ let workspace = NSWorkspace.shared
+ let url = Bundle.main.bundleURL
+ .appendingPathComponent("Contents")
+ .appendingPathComponent("Applications")
+ .appendingPathComponent("CopilotForXcodeExtensionService.app")
+ workspace.activateFileViewerSelecting([url])
+ }
+ }) {
+ Text("Reveal Extension Service in Finder")
}
- }) {
- Text("Reveal Extension Service in Finder")
- }
- Button(action: {
- let url = URL(
- string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"
- )!
- NSWorkspace.shared.open(url)
- }) {
- Text("Accessibility Settings")
- }
+ Button(action: {
+ let url = URL(
+ string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"
+ )!
+ NSWorkspace.shared.open(url)
+ }) {
+ Text("Accessibility Settings")
+ }
- Button(action: {
- let url = URL(
- string: "x-apple.systempreferences:com.apple.ExtensionsPreferences"
- )!
- NSWorkspace.shared.open(url)
- }) {
- Text("Extensions Settings")
+ Button(action: {
+ let url = URL(
+ string: "x-apple.systempreferences:com.apple.ExtensionsPreferences"
+ )!
+ NSWorkspace.shared.open(url)
+ }) {
+ Text("Extensions Settings")
+ }
}
}
}
@@ -152,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()
}
}
@@ -245,7 +205,7 @@ struct GeneralSettingsView: View {
@StateObject var settings = Settings()
@Environment(\.updateChecker) var updateChecker
@State var automaticallyCheckForUpdate: Bool?
-
+
var body: some View {
Form {
Toggle(isOn: $settings.quitXPCServiceOnXcodeAndAppQuit) {
@@ -261,7 +221,7 @@ struct GeneralSettingsView: View {
)) {
Text("Automatically Check for Update")
}
-
+
Toggle(isOn: $settings.installBetaBuilds) {
Text("Install beta builds")
}
@@ -320,7 +280,7 @@ struct GeneralSettingsView: View {
}
Toggle(isOn: $settings.hideCircularWidget) {
- Text("Hide circular widget")
+ Text("Hide indicator widget")
}
}.padding()
}
@@ -394,7 +354,7 @@ struct LargeIconPicker<
struct GeneralView_Previews: PreviewProvider {
static var previews: some View {
- GeneralView(store: .init(initialState: .init(), reducer: General()))
+ GeneralView(store: .init(initialState: .init(), reducer: { General() }))
.frame(height: 800)
}
}
diff --git a/Core/Sources/HostApp/HostApp.swift b/Core/Sources/HostApp/HostApp.swift
index e5379319..f2b90303 100644
--- a/Core/Sources/HostApp/HostApp.swift
+++ b/Core/Sources/HostApp/HostApp.swift
@@ -4,26 +4,29 @@ import Foundation
import KeyboardShortcuts
#if canImport(LicenseManagement)
-import LicenseManagement
+import ProHostApp
#endif
extension KeyboardShortcuts.Name {
static let showHideWidget = Self("ShowHideWidget")
}
-struct HostApp: ReducerProtocol {
+@Reducer
+struct HostApp {
+ @ObservableState
struct State: Equatable {
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
@@ -32,38 +35,30 @@ struct HostApp: ReducerProtocol {
KeyboardShortcuts.userDefaults = .shared
}
- var body: some ReducerProtocol {
- Scope(state: \.general, action: /Action.general) {
+ var body: some ReducerOf {
+ 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
@@ -73,6 +68,9 @@ struct HostApp: ReducerProtocol {
case .embeddingModelManagement:
return .none
+
+ case .webSearchSettings:
+ return .none
}
}
}
diff --git a/Core/Sources/HostApp/LaunchAgentManager.swift b/Core/Sources/HostApp/LaunchAgentManager.swift
index db4d48b9..44937bb1 100644
--- a/Core/Sources/HostApp/LaunchAgentManager.swift
+++ b/Core/Sources/HostApp/LaunchAgentManager.swift
@@ -6,16 +6,14 @@ extension LaunchAgentManager {
self.init(
serviceIdentifier: Bundle.main
.object(forInfoDictionaryKey: "BUNDLE_IDENTIFIER_BASE") as! String +
- ".ExtensionService",
- executablePath: Bundle.main.bundleURL
+ ".CommunicationBridge",
+ executableURL: Bundle.main.bundleURL
.appendingPathComponent("Contents")
.appendingPathComponent("Applications")
- .appendingPathComponent(
- "CopilotForXcodeExtensionService.app/Contents/MacOS/CopilotForXcodeExtensionService"
- )
- .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 b0e6bf59..bf81eb51 100644
--- a/Core/Sources/HostApp/ServiceView.swift
+++ b/Core/Sources/HostApp/ServiceView.swift
@@ -1,64 +1,79 @@
-import SwiftUI
import ComposableArchitecture
+import SwiftUI
struct ServiceView: View {
let store: StoreOf
@State var tag = 0
-
+
var body: some View {
- SidebarTabView(tag: $tag) {
- ScrollView {
- GitHubCopilotView().padding()
- }.sidebarItem(
- tag: 0,
- title: "GitHub Copilot",
- subtitle: "Suggestion",
- image: "globe"
- )
-
- ScrollView {
- CodeiumView().padding()
- }.sidebarItem(
- tag: 1,
- title: "Codeium",
- subtitle: "Suggestion",
- image: "globe"
- )
-
- ChatModelManagementView(store: store.scope(
- state: \.chatModelManagement,
- action: HostApp.Action.chatModelManagement
- )).sidebarItem(
- tag: 2,
- title: "Chat Models",
- subtitle: "Chat, Prompt to Code",
- image: "globe"
- )
-
- EmbeddingModelManagementView(store: store.scope(
- state: \.embeddingModelManagement,
- action: HostApp.Action.embeddingModelManagement
- )).sidebarItem(
- tag: 3,
- title: "Embedding Models",
- subtitle: "Chat, Prompt to Code",
- image: "globe"
- )
-
- ScrollView {
- BingSearchView().padding()
- }.sidebarItem(
- tag: 4,
- title: "Bing Search",
- subtitle: "Search Chat Plugin",
- image: "globe"
- )
+ WithPerceptionTracking {
+ SidebarTabView(tag: $tag) {
+ WithPerceptionTracking {
+ ScrollView {
+ GitHubCopilotView().padding()
+ }.sidebarItem(
+ tag: 0,
+ title: "GitHub Copilot",
+ subtitle: "Suggestion",
+ image: "globe"
+ )
+
+ ScrollView {
+ CodeiumView().padding()
+ }.sidebarItem(
+ tag: 1,
+ title: "Codeium",
+ subtitle: "Suggestion",
+ image: "globe"
+ )
+
+ ChatModelManagementView(store: store.scope(
+ state: \.chatModelManagement,
+ action: \.chatModelManagement
+ )).sidebarItem(
+ tag: 2,
+ title: "Chat Models",
+ subtitle: "Chat, Modification",
+ image: "globe"
+ )
+
+ EmbeddingModelManagementView(store: store.scope(
+ state: \.embeddingModelManagement,
+ action: \.embeddingModelManagement
+ )).sidebarItem(
+ tag: 3,
+ title: "Embedding Models",
+ subtitle: "Chat, Modification",
+ image: "globe"
+ )
+
+ WebSearchView(store: store.scope(
+ state: \.webSearchSettings,
+ action: \.webSearchSettings
+ )).sidebarItem(
+ tag: 4,
+ title: "Web Search",
+ subtitle: "Chat, Modification",
+ image: "globe"
+ )
+
+ ScrollView {
+ OtherSuggestionServicesView().padding()
+ }.sidebarItem(
+ tag: 5,
+ title: "Other Suggestion Services",
+ subtitle: "Suggestion",
+ image: "globe"
+ )
+ }
+ }
}
}
}
struct AccountView_Previews: PreviewProvider {
static var previews: some View {
- ServiceView(store: .init(initialState: .init(), reducer: HostApp()))
+ ServiceView(store: .init(initialState: .init(), reducer: { HostApp() }))
}
}
+
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 ac4bbb40..8616b5af 100644
--- a/Core/Sources/HostApp/TabContainer.swift
+++ b/Core/Sources/HostApp/TabContainer.swift
@@ -2,16 +2,13 @@ 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())
+let hostAppStore: StoreOf = .init(initialState: .init(), reducer: { HostApp() })
public struct TabContainer: View {
let store: StoreOf
@@ -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
@@ -30,62 +31,65 @@ public struct TabContainer: View {
}
public var body: some View {
- VStack(spacing: 0) {
- TabBar(tag: $tag, tabBarItems: tabBarItems)
- .padding(.bottom, 8)
-
- Divider()
-
- ZStack(alignment: .center) {
- GeneralView(store: store.scope(state: \.general, action: HostApp.Action.general))
- .tabBarItem(
- tag: 0,
- title: "General",
- image: "app.gift"
+ WithPerceptionTracking {
+ VStack(spacing: 0) {
+ TabBar(tag: $tag, tabBarItems: tabBarItems)
+ .padding(.bottom, 8)
+
+ Divider()
+
+ ZStack(alignment: .center) {
+ GeneralView(store: store.scope(state: \.general, action: \.general))
+ .tabBarItem(
+ tag: 0,
+ title: "General",
+ image: "app.gift"
+ )
+ ServiceView(store: store).tabBarItem(
+ tag: 1,
+ title: "Service",
+ image: "globe"
)
- ServiceView(store: store).tabBarItem(
- tag: 1,
- title: "Service",
- image: "globe"
- )
- FeatureSettingsView().tabBarItem(
- tag: 2,
- title: "Feature",
- image: "star.square"
- )
- CustomCommandView(store: customCommandStore).tabBarItem(
- tag: 3,
- title: "Custom Command",
- image: "command.square"
- )
- #if canImport(ProHostApp)
- PlusView(onLicenseKeyChanged: {
- store.send(.informExtensionServiceAboutLicenseKeyChange)
- }).tabBarItem(
- tag: 5,
- title: "Plus",
- image: "plus.diamond"
- )
- #endif
- DebugSettingsView().tabBarItem(
- tag: 4,
- title: "Advanced",
- image: "gearshape.2"
- )
+ FeatureSettingsView().tabBarItem(
+ tag: 2,
+ title: "Feature",
+ image: "star.square"
+ )
+ CustomCommandView(store: customCommandStore).tabBarItem(
+ tag: 3,
+ title: "Custom Command",
+ image: "command.square"
+ )
+
+ 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)
@@ -84,8 +53,12 @@ public struct LaunchAgentManager {
public func removeLaunchAgent() async throws {
if #available(macOS 13, *) {
- let launchAgent = SMAppService.agent(plistName: "launchAgent.plist")
- try await launchAgent.unregister()
+ let bridgeLaunchAgent = SMAppService.agent(plistName: "bridgeLaunchAgent.plist")
+ 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 6804ecf8..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
@@ -38,6 +69,7 @@ public final class OpenAIPromptToCodeService: PromptToCodeServiceType {
lines: source.lines,
selections: [source.range],
cursorPosition: .outOfScope,
+ cursorOffset: -1,
lineAnnotations: []
),
selectedContent: code,
@@ -175,61 +207,80 @@ 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)
{
- func extractCodeFromMarkdown(_ markdown: String) -> (code: String, endIndex: Int)? {
+ func extractCodeFromMarkdown(
+ _ markdown: String
+ ) -> (code: String, endIndex: String.Index)? {
let codeBlockRegex = try! NSRegularExpression(
pattern: #"```(?:\w+)?\R([\s\S]+?)\R```"#,
options: .dotMatchesLineSeparators
)
let range = NSRange(markdown.startIndex.. String {
- let startIndex = markdown.index(markdown.startIndex, offsetBy: startIndex)
+ func extractDescriptionFromMarkdown(
+ _ markdown: String,
+ startIndex: String.Index
+ ) -> String {
guard startIndex < markdown.endIndex else { return "" }
let range = startIndex.. 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 4f17fc81..dfbd719a 100644
--- a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift
+++ b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift
@@ -1,27 +1,27 @@
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
-struct GUI: ReducerProtocol {
- struct State: Equatable {
- var suggestionWidgetState = WidgetFeature.State()
+@Reducer
+struct GUI {
+ @ObservableState
+ struct State {
+ var suggestionWidgetState = Widget.State()
- var chatTabGroup: ChatPanelFeature.ChatTabGroup {
+ var chatTabGroup: SuggestionWidget.ChatPanel.ChatTabGroup {
get { suggestionWidgetState.chatPanelState.chatTabGroup }
set { suggestionWidgetState.chatPanelState.chatTabGroup = newValue }
}
@@ -52,12 +52,16 @@ struct GUI: ReducerProtocol {
enum Action {
case start
- case openChatPanel(forceDetach: Bool)
- case createChatGPTChatTabIfNeeded
+ case openChatPanel(forceDetach: Bool, activateThisApp: Bool)
+ case createAndSwitchToChatGPTChatTabIfNeeded
+ 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))))
@@ -75,15 +79,15 @@ struct GUI: ReducerProtocol {
case updateChatTabOrder
}
- var body: some ReducerProtocol {
+ var body: some ReducerOf {
CombineReducers {
- Scope(state: \.suggestionWidgetState, action: /Action.suggestionWidget) {
- WidgetFeature()
+ Scope(state: \.suggestionWidgetState, action: \.suggestionWidget) {
+ Widget()
}
Scope(
state: \.chatTabGroup,
- action: /Action.suggestionWidget .. /WidgetFeature.Action.chatPanel
+ action: \.suggestionWidget.chatPanel
) {
Reduce { _, action in
switch action {
@@ -99,7 +103,7 @@ struct GUI: ReducerProtocol {
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)
@@ -115,7 +119,7 @@ struct GUI: ReducerProtocol {
}
#if canImport(ChatTabPersistent)
- Scope(state: \.persistentState, action: /Action.persistent) {
+ Scope(state: \.persistentState, action: \.persistent) {
ChatTabPersistent()
}
#endif
@@ -131,7 +135,7 @@ struct GUI: ReducerProtocol {
return .none
#endif
- case let .openChatPanel(forceDetach):
+ case let .openChatPanel(forceDetach, activate):
return .run { send in
await send(
.suggestionWidget(
@@ -140,17 +144,42 @@ struct GUI: ReducerProtocol {
)
await send(.suggestionWidget(.updateKeyWindow(.chatPanel)))
- activateThisApp()
+ if activate {
+ activateThisApp()
+ }
}
- case .createChatGPTChatTabIfNeeded:
- if state.chatTabGroup.tabInfo.contains(where: {
- chatTabPool.getTab(of: $0.id) is ChatGPTChatTab
- }) {
+ case .createAndSwitchToChatGPTChatTabIfNeeded:
+ return .run { send in
+ 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),
+ check(tab)
+ {
+ // Already in ChatGPT tab
return .none
}
+
+ if let firstChatGPTTabInfo = state.chatTabGroup.tabInfo.first(where: {
+ if let tab = chatTabPool.getTab(of: $0.id) {
+ return check(tab)
+ }
+ return false
+ }) {
+ return .run { send in
+ await send(.suggestionWidget(.chatPanel(.tabClicked(
+ id: firstChatGPTTabInfo.id
+ ))))
+ }
+ }
return .run { send in
- if let (_, chatTabInfo) = await chatTabPool.createTab(for: nil) {
+ if let (_, chatTabInfo) = await chatTabPool.createTab(for: kind) {
await send(
.suggestionWidget(.chatPanel(.appendAndSelectTab(chatTabInfo)))
)
@@ -158,44 +187,35 @@ struct GUI: ReducerProtocol {
}
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:
@@ -203,7 +223,7 @@ struct GUI: ReducerProtocol {
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
@@ -251,10 +271,9 @@ struct GUI: ReducerProtocol {
@MainActor
public final class GraphicalUserInterfaceController {
- private let store: StoreOf
+ let store: StoreOf
let widgetController: SuggestionWidgetController
let widgetDataSource: WidgetDataSource
- let viewStore: ViewStoreOf
let chatTabPool: ChatTabPool
class WeakStoreHolder {
@@ -269,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 = {
@@ -289,18 +297,17 @@ public final class GraphicalUserInterfaceController {
}
let store = StoreOf(
initialState: .init(),
- reducer: GUI(),
- prepareDependencies: setupDependency
+ reducer: { GUI() },
+ withDependencies: setupDependency
)
self.store = store
self.chatTabPool = chatTabPool
- viewStore = ViewStore(store)
widgetDataSource = .init()
widgetController = SuggestionWidgetController(
store: store.scope(
state: \.suggestionWidgetState,
- action: GUI.Action.suggestionWidget
+ action: \.suggestionWidget
),
chatTabPool: chatTabPool,
dependency: suggestionDependency
@@ -309,20 +316,28 @@ public final class GraphicalUserInterfaceController {
chatTabPool.createStore = { id in
store.scope(
state: { state in
- state.chatTabGroup.tabInfo[id: id]
- ?? .init(id: id, title: "")
+ 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?.viewStore.send(.createChatGPTChatTabIfNeeded).finish()
- self?.viewStore.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
@@ -338,10 +353,7 @@ public final class GraphicalUserInterfaceController {
}
public func openGlobalChat() {
- Task {
- await self.viewStore.send(.createChatGPTChatTabIfNeeded).finish()
- viewStore.send(.openChatPanel(forceDetach: true))
- }
+ PseudoCommandHandler().openChat(forceDetach: true)
}
}
@@ -354,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)
}
@@ -364,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 3ed6a69c..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