diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml
index ec05061a..86a41f25 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yaml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yaml
@@ -13,7 +13,7 @@ body:
id: before-reporting
attributes:
label: Before Reporting
- description: Before reporting the bug, we suggestion that you first refer to the [FAQ](https://github.com/intitni/CopilotForXcode/wiki/Frequently-Asked-Questions) to check if it may address your issue. And search for existing issues to avoid duplication.
+ description: Before reporting the bug, we suggestion that you first refer to the [FAQ](https://github.com/intitni/CopilotForXcode/wiki/Frequently-Asked-Questions) to check if it may address your issue. And search for existing issues to avoid duplication. If you are reporting a bug from a beta build, please use the dedicated template for beta build.
options:
- label: I have checked FAQ, and there is no solution to my issue
required: true
@@ -32,9 +32,9 @@ body:
id: reproduce
attributes:
label: How to reproduce the bug.
- description: If possible, please provide the steps to reproduce the bug.
+ description: If possible, please provide the steps to reproduce the bug and relevant settings in screenshots.
placeholder: "1. *****\n2.*****"
- value: "It just happens!"
+ value: "It just happened!"
- type: textarea
id: logs
attributes:
@@ -53,8 +53,4 @@ body:
id: copilot-for-xcode-version
attributes:
label: Copilot for Xcode version
- - type: input
- id: node-version
- attributes:
- label: Node version
diff --git a/.github/ISSUE_TEMPLATE/feature_reqeust.yaml b/.github/ISSUE_TEMPLATE/feature_reqeust.yaml
index adc4753d..0c034eed 100644
--- a/.github/ISSUE_TEMPLATE/feature_reqeust.yaml
+++ b/.github/ISSUE_TEMPLATE/feature_reqeust.yaml
@@ -8,7 +8,7 @@ body:
- type: markdown
attributes:
value: |
- Thanks for taking the time to fill out this feature request!
+ Thanks for taking the time to fill out this feature request! But please firstly [post your idea in discussion](https://github.com/intitni/CopilotForXcode/discussions/new?category=ideas) so that we can discuss about it in advance.
- type: checkboxes
id: before-reporting
attributes:
@@ -25,4 +25,4 @@ body:
placeholder: Tell us what you want!
value: "I want a feature!"
validations:
- required: true
\ No newline at end of file
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/z_bug_report_beta.yaml b/.github/ISSUE_TEMPLATE/z_bug_report_beta.yaml
new file mode 100644
index 00000000..2f80eaaf
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/z_bug_report_beta.yaml
@@ -0,0 +1,56 @@
+name: Bug Report (Beta)
+description: File a bug report
+title: "[Bug (Beta)]: "
+labels: ["bug", "beta"]
+assignees:
+ - intitni
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thanks for taking the time to fill out this bug report!
+ - type: checkboxes
+ id: before-reporting
+ attributes:
+ label: Before Reporting
+ description: Before reporting the bug, we suggestion that you first refer to the [FAQ](https://github.com/intitni/CopilotForXcode/wiki/Frequently-Asked-Questions) to check if it may address your issue. And search for existing issues to avoid duplication.
+ options:
+ - label: I have checked FAQ, and there is no solution to my issue
+ required: true
+ - label: I have searched the existing issues, and there is no existing issue for my issue
+ required: true
+ - type: textarea
+ id: what-happened
+ attributes:
+ label: What happened?
+ description: Also tell us, what did you expect to happen?
+ placeholder: Tell us what you see!
+ value: "A bug happened!"
+ validations:
+ required: true
+ - type: textarea
+ id: reproduce
+ attributes:
+ label: How to reproduce the bug.
+ description: If possible, please provide the steps to reproduce the bug and relevant settings in screenshots.
+ placeholder: "1. *****\n2.*****"
+ value: "It just happened!"
+ - type: textarea
+ id: logs
+ attributes:
+ label: Relevant log output
+ description: If it's a crash, please provide the crash report. You can find it in the Console.app.
+ render: shell
+ - type: input
+ id: mac-version
+ attributes:
+ label: macOS version
+ - type: input
+ id: xcode-version
+ attributes:
+ label: Xcode version
+ - type: input
+ id: copilot-for-xcode-version
+ attributes:
+ label: Copilot for Xcode version
+
diff --git a/.github/workflows/close_inactive_issues.yml b/.github/workflows/close_inactive_issues.yml
index 6be38831..1fae3c1f 100644
--- a/.github/workflows/close_inactive_issues.yml
+++ b/.github/workflows/close_inactive_issues.yml
@@ -15,7 +15,7 @@ jobs:
days-before-issue-stale: 30
days-before-issue-close: 14
stale-issue-label: "stale"
- exempt-issue-labels: "low priority, help wanted, planned"
+ exempt-issue-labels: "low priority, help wanted, planned, investigating, blocked"
stale-issue-message: "This issue is stale because it has been open for 30 days with no activity."
close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale."
days-before-pr-stale: -1
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 ca8e5bfe..056e5761 100644
--- a/Copilot for Xcode.xcodeproj/project.pbxproj
+++ b/Copilot for Xcode.xcodeproj/project.pbxproj
@@ -23,6 +23,7 @@
C8216B782980370100AD38C7 /* ReloadLaunchAgent.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8216B772980370100AD38C7 /* ReloadLaunchAgent.swift */; };
C8216B7D2980374300AD38C7 /* ArgumentParser in Frameworks */ = {isa = PBXBuildFile; productRef = C8216B7C2980374300AD38C7 /* ArgumentParser */; };
C8216B802980378300AD38C7 /* Helper in Embed XPCService */ = {isa = PBXBuildFile; fileRef = C8216B70298036EC00AD38C7 /* Helper */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
+ C828B27F2B1F7B4F00E7612A /* ExtensionPoint.appextensionpoint in Copy Extension Point */ = {isa = PBXBuildFile; fileRef = C828B27D2B1F241500E7612A /* ExtensionPoint.appextensionpoint */; };
C8520301293C4D9000460097 /* Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8520300293C4D9000460097 /* Helpers.swift */; };
C861A6A329E5503F005C41A3 /* PromptToCodeCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C861A6A229E5503F005C41A3 /* PromptToCodeCommand.swift */; };
C861E6112994F6070056CB02 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C861E6102994F6070056CB02 /* AppDelegate.swift */; };
@@ -30,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 */; };
@@ -41,8 +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 */; };
- C8F1032B2A7A39D700D28F4F /* launchAgent.plist in Copy Launch Agent */ = {isa = PBXBuildFile; fileRef = C8F103292A7A365000D28F4F /* launchAgent.plist */; };
+ C8DCF00029CE11D500FDDDD7 /* OpenChat.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8DCEFFF29CE11D500FDDDD7 /* OpenChat.swift */; };
+ C8DD9CB12BC673F80036641C /* CloseIdleTabsCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8DD9CB02BC673F80036641C /* CloseIdleTabsCommand.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -67,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 */
@@ -90,18 +109,39 @@
);
runOnlyForDeploymentPostprocessing = 1;
};
+ C828B27E2B1F7B3C00E7612A /* Copy Extension Point */ = {
+ isa = PBXCopyFilesBuildPhase;
+ buildActionMask = 2147483647;
+ dstPath = "$(EXTENSIONS_FOLDER_PATH)";
+ dstSubfolderSpec = 16;
+ files = (
+ C828B27F2B1F7B4F00E7612A /* ExtensionPoint.appextensionpoint in Copy Extension Point */,
+ );
+ name = "Copy Extension Point";
+ runOnlyForDeploymentPostprocessing = 0;
+ };
C8520306293CF0EF00460097 /* Embed XPCService */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
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;
@@ -129,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;
@@ -163,8 +203,9 @@
C8216B70298036EC00AD38C7 /* Helper */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = Helper; sourceTree = BUILT_PRODUCTS_DIR; };
C8216B72298036EC00AD38C7 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; };
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 = ""; };
@@ -173,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; };
@@ -182,8 +235,10 @@
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 */
@@ -222,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 */
@@ -250,7 +321,8 @@
C8009C022941C576007AA7E8 /* RealtimeSuggestionCommand.swift */,
C800DBB0294C624D00B04CAC /* PrefetchSuggestionsCommand.swift */,
C8758E6F29F04BFF00D29C1C /* CustomCommand.swift */,
- C8DCEFFF29CE11D500FDDDD7 /* ChatWithSelection.swift */,
+ C8DCEFFF29CE11D500FDDDD7 /* OpenChat.swift */,
+ C8DD9CB02BC673F80036641C /* CloseIdleTabsCommand.swift */,
C861A6A229E5503F005C41A3 /* PromptToCodeCommand.swift */,
C81458972939EFDC00135263 /* Info.plist */,
C81458982939EFDC00135263 /* EditorExtension.entitlements */,
@@ -264,18 +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 */,
);
@@ -288,6 +365,8 @@
C814588C2939EFDC00135263 /* Copilot.appex */,
C8216B70298036EC00AD38C7 /* Helper */,
C861E60E2994F6070056CB02 /* CopilotForXcodeExtensionService.app */,
+ C8738B632BE4D4B900609E7F /* CommunicationBridge */,
+ C8738B782BE5363800609E7F /* SandboxedClientTester.app */,
);
name = Products;
sourceTree = "";
@@ -328,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 */,
@@ -335,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 */
@@ -374,6 +484,7 @@
buildRules = (
);
dependencies = (
+ C8738B8D2BE540F900609E7F /* PBXTargetDependency */,
C81291B02994F92700196E12 /* PBXTargetDependency */,
C8216B7F2980377E00AD38C7 /* PBXTargetDependency */,
C814589A2939EFDC00135263 /* PBXTargetDependency */,
@@ -413,8 +524,7 @@
C861E60A2994F6070056CB02 /* Sources */,
C861E60B2994F6070056CB02 /* Frameworks */,
C861E60C2994F6070056CB02 /* Resources */,
- C8A3AE572A28852D0046E809 /* Sign Python STD */,
- C8A3B1782A2894E10046E809 /* Sign Python Site Packages */,
+ C828B27E2B1F7B3C00E7612A /* Copy Extension Point */,
);
buildRules = (
);
@@ -428,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 */
@@ -435,7 +585,7 @@
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
- LastSwiftUpdateCheck = 1420;
+ LastSwiftUpdateCheck = 1520;
LastUpgradeCheck = 1410;
TargetAttributes = {
C814588B2939EFDC00135263 = {
@@ -450,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" */;
@@ -473,6 +629,8 @@
C814588B2939EFDC00135263 /* EditorExtension */,
C8216B6F298036EC00AD38C7 /* Helper */,
C861E60D2994F6070056CB02 /* ExtensionService */,
+ C8738B622BE4D4B900609E7F /* CommunicationBridge */,
+ C8738B772BE5363800609E7F /* SandboxedClientTester */,
);
};
/* End PBXProject section */
@@ -503,56 +661,25 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
-/* End PBXResourcesBuildPhase section */
-
-/* Begin PBXShellScriptBuildPhase section */
- C8A3AE572A28852D0046E809 /* Sign Python STD */ = {
- isa = PBXShellScriptBuildPhase;
- alwaysOutOfDate = 1;
- buildActionMask = 8;
- files = (
- );
- inputFileListPaths = (
- );
- inputPaths = (
- );
- name = "Sign Python STD";
- outputFileListPaths = (
- );
- outputPaths = (
- );
- runOnlyForDeploymentPostprocessing = 1;
- shellPath = /bin/sh;
- shellScript = "#set -e\n#echo \"Signing as $EXPANDED_CODE_SIGN_IDENTITY_NAME ($EXPANDED_CODE_SIGN_IDENTITY)\"\n#find \"$CODESIGNING_FOLDER_PATH/Contents/Resources/python-stdlib/lib-dynload\" -name \"*.so\" -exec /usr/bin/codesign --force --sign \"$EXPANDED_CODE_SIGN_IDENTITY\" -o runtime --timestamp=none --preserve-metadata=identifier,entitlements,flags --generate-entitlement-der {} \\;\n";
- };
- C8A3B1782A2894E10046E809 /* Sign Python Site Packages */ = {
- isa = PBXShellScriptBuildPhase;
- alwaysOutOfDate = 1;
- buildActionMask = 8;
+ C8738B762BE5363800609E7F /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
files = (
+ C8738B822BE5363900609E7F /* Preview Assets.xcassets in Resources */,
+ C8738B7F2BE5363900609E7F /* Assets.xcassets in Resources */,
);
- inputFileListPaths = (
- );
- inputPaths = (
- );
- name = "Sign Python Site Packages";
- outputFileListPaths = (
- );
- outputPaths = (
- );
- runOnlyForDeploymentPostprocessing = 1;
- shellPath = /bin/sh;
- shellScript = "#set -e\n#echo \"Signing as $EXPANDED_CODE_SIGN_IDENTITY_NAME ($EXPANDED_CODE_SIGN_IDENTITY)\"\n#find \"$CODESIGNING_FOLDER_PATH/Contents/Resources/site-packages\" -type f \\( -name \"*.so\" -o -name \"*.dylib\" \\) -exec /usr/bin/codesign --force --sign \"$EXPANDED_CODE_SIGN_IDENTITY\" -o runtime --timestamp=none --preserve-metadata=identifier,entitlements,flags --generate-entitlement-der {} \\;\n";
+ runOnlyForDeploymentPostprocessing = 0;
};
-/* End PBXShellScriptBuildPhase section */
+/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
C81458882939EFDC00135263 /* Sources */ = {
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 */,
C8758E7229F04CF100D29C1C /* SeparatorCommand.swift in Sources */,
C861A6A329E5503F005C41A3 /* PromptToCodeCommand.swift in Sources */,
@@ -591,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 */
@@ -614,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 */
@@ -624,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;
@@ -651,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;
@@ -705,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;
@@ -766,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;
@@ -796,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;
@@ -828,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)";
@@ -853,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;
@@ -866,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;
@@ -884,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;
@@ -897,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)";
@@ -916,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;
@@ -929,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)";
@@ -939,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 */
@@ -987,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 */
@@ -1022,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
}
+
+ 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 {
- // include the pro package
- return self + [.package(path: "../Pro")]
+ return self + [.package(path: "../../Pro"), .package(path: "../../Pro/ProCore")]
}
return self
}
}
-import Foundation
-
-let isProIncluded: Bool = {
+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")
- if !FileManager.default.fileExists(atPath: confURL.path) {
- return false
- }
- do {
- let content = String(
- data: try Data(contentsOf: confURL),
- encoding: .utf8
- )
- print("")
- return content?.hasPrefix("YES") ?? false
- } catch {
- return false
- }
+ return FileManager.default.fileExists(atPath: confURL.path)
}
return isProIncluded()
-}()
+}
diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/ExpandFocusRangeFunction.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/ExpandFocusRangeFunction.swift
deleted file mode 100644
index a70efe36..00000000
--- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/ExpandFocusRangeFunction.swift
+++ /dev/null
@@ -1,60 +0,0 @@
-import ASTParser
-import Foundation
-import OpenAIService
-import SuggestionModel
-
-struct ExpandFocusRangeFunction: ChatGPTFunction {
- struct Arguments: Codable {}
-
- struct Result: ChatGPTFunctionResult {
- var range: CursorRange
-
- var botReadableContent: String {
- "Editing Document Context is updated to display code at \(range)."
- }
- }
-
- struct E: Error, LocalizedError {
- var errorDescription: String?
- }
-
- var name: String {
- "expandFocusRange"
- }
-
- var description: String {
- "Call when Editing Document Context provides too little context to answer a question."
- }
-
- var argumentSchema: JSONSchemaValue { [
- .type: "object",
- .properties: [:],
- ] }
-
- weak var contextCollector: ActiveDocumentChatContextCollector?
-
- init(contextCollector: ActiveDocumentChatContextCollector) {
- self.contextCollector = contextCollector
- }
-
- func prepare(reportProgress: @escaping (String) async -> Void) async {
- await reportProgress("Finding the focused code..")
- }
-
- func call(
- arguments: Arguments,
- reportProgress: @escaping (String) async -> Void
- ) async throws -> Result {
- await reportProgress("Finding the focused code..")
- contextCollector?.activeDocumentContext?.expandFocusedRangeToContextRange()
- guard let newContext = contextCollector?.activeDocumentContext?.focusedContext else {
- let progress = "Failed to expand focused code."
- await reportProgress(progress)
- throw E(errorDescription: progress)
- }
- let progress = "Looking at \(newContext.codeRange)."
- await reportProgress(progress)
- return .init(range: newContext.codeRange)
- }
-}
-
diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToCodeAroundLineFunction.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToCodeAroundLineFunction.swift
deleted file mode 100644
index 42ee50a2..00000000
--- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToCodeAroundLineFunction.swift
+++ /dev/null
@@ -1,68 +0,0 @@
-import ASTParser
-import Foundation
-import OpenAIService
-import SuggestionModel
-
-struct MoveToCodeAroundLineFunction: ChatGPTFunction {
- struct Arguments: Codable {
- var line: Int
- }
-
- struct Result: ChatGPTFunctionResult {
- var range: CursorRange
-
- var botReadableContent: String {
- "Editing Document Context is updated to display code at \(range)."
- }
- }
-
- struct E: Error, LocalizedError {
- var errorDescription: String?
- }
-
- var name: String {
- "getCodeAtLine"
- }
-
- var description: String {
- "Get the code at the given line, so you can answer the question about the code at that line."
- }
-
- var argumentSchema: JSONSchemaValue { [
- .type: "object",
- .properties: [
- "line": [
- .type: "number",
- .description: "The line number in the file",
- ],
- ],
- .required: ["line"],
- ] }
-
- weak var contextCollector: ActiveDocumentChatContextCollector?
-
- init(contextCollector: ActiveDocumentChatContextCollector) {
- self.contextCollector = contextCollector
- }
-
- func prepare(reportProgress: @escaping (String) async -> Void) async {
- await reportProgress("Finding code around..")
- }
-
- func call(
- arguments: Arguments,
- reportProgress: @escaping (String) async -> Void
- ) async throws -> Result {
- await reportProgress("Finding code around line \(arguments.line)..")
- contextCollector?.activeDocumentContext?.moveToCodeAroundLine(arguments.line)
- guard let newContext = contextCollector?.activeDocumentContext?.focusedContext else {
- let progress = "Failed to move to focused code."
- await reportProgress(progress)
- throw E(errorDescription: progress)
- }
- let progress = "Looking at \(newContext.codeRange)"
- await reportProgress(progress)
- return .init(range: newContext.codeRange)
- }
-}
-
diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToFocusedCodeFunction.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToFocusedCodeFunction.swift
deleted file mode 100644
index 3b3096a2..00000000
--- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToFocusedCodeFunction.swift
+++ /dev/null
@@ -1,55 +0,0 @@
-import ASTParser
-import Foundation
-import OpenAIService
-import SuggestionModel
-
-struct MoveToFocusedCodeFunction: ChatGPTFunction {
- typealias Arguments = NoArguments
-
- struct Result: ChatGPTFunctionResult {
- var range: CursorRange
-
- var botReadableContent: String {
- "Editing Document Context is updated to display code at \(range)."
- }
- }
-
- struct E: Error, LocalizedError {
- var errorDescription: String?
- }
-
- var name: String {
- "moveToFocusedCode"
- }
-
- var description: String {
- "Move editing document context to the selected or focused code"
- }
-
- weak var contextCollector: ActiveDocumentChatContextCollector?
-
- init(contextCollector: ActiveDocumentChatContextCollector) {
- self.contextCollector = contextCollector
- }
-
- func prepare(reportProgress: @escaping (String) async -> Void) async {
- await reportProgress("Finding the focused code..")
- }
-
- func call(
- arguments: Arguments,
- reportProgress: @escaping (String) async -> Void
- ) async throws -> Result {
- await reportProgress("Finding the focused code..")
- contextCollector?.activeDocumentContext?.moveToFocusedCode()
- guard let newContext = contextCollector?.activeDocumentContext?.focusedContext else {
- let progress = "Failed to move to focused code."
- await reportProgress(progress)
- throw E(errorDescription: progress)
- }
- let progress = "Looking at \(newContext.codeRange)."
- await reportProgress(progress)
- return .init(range: newContext.codeRange)
- }
-}
-
diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetEditorInfo.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetEditorInfo.swift
deleted file mode 100644
index 3907568a..00000000
--- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetEditorInfo.swift
+++ /dev/null
@@ -1,8 +0,0 @@
-import Foundation
-import SuggestionModel
-import XcodeInspector
-
-func getEditorInformation() -> EditorInformation? {
- return XcodeInspector.shared.focusedEditorContent
-}
-
diff --git a/Core/Sources/ChatContextCollectors/SystemInfoChatContextCollector/SystemInfoChatContextCollector.swift b/Core/Sources/ChatContextCollectors/SystemInfoChatContextCollector/SystemInfoChatContextCollector.swift
index 19aab821..9686ca85 100644
--- a/Core/Sources/ChatContextCollectors/SystemInfoChatContextCollector/SystemInfoChatContextCollector.swift
+++ b/Core/Sources/ChatContextCollectors/SystemInfoChatContextCollector/SystemInfoChatContextCollector.swift
@@ -13,14 +13,19 @@ public final class SystemInfoChatContextCollector: ChatContextCollector {
public func generateContext(
history: [ChatMessage],
- scopes: Set,
+ scopes: Set,
content: String,
configuration: ChatGPTConfiguration
- ) -> ChatContext? {
+ ) -> ChatContext {
return .init(
systemPrompt: """
- Current Time: \(Self.dateFormatter.string(from: Date())) (You can use it to calculate time in another time zone)
+ ## System Info
+
+ Current Time: \(
+ Self.dateFormatter.string(from: Date())
+ ) (You can use it to calculate time in another time zone)
""",
+ retrievedContent: [],
functions: []
)
}
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 bf72667f..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
@@ -9,11 +10,11 @@ public final class WebChatContextCollector: ChatContextCollector {
public func generateContext(
history: [ChatMessage],
- scopes: Set,
+ scopes: Set,
content: String,
configuration: ChatGPTConfiguration
- ) -> ChatContext? {
- guard scopes.contains("web") || scopes.contains("w") else { return nil }
+ ) -> ChatContext {
+ guard scopes.contains(.web) else { return .empty }
let links = Self.detectLinks(from: history) + Self.detectLinks(from: content)
let functions: [(any ChatGPTFunction)?] = [
SearchFunction(maxTokens: configuration.maxTokens),
@@ -22,6 +23,7 @@ public final class WebChatContextCollector: ChatContextCollector {
]
return .init(
systemPrompt: "You prefer to answer questions with latest content on the internet.",
+ retrievedContent: [],
functions: functions.compactMap { $0 }
)
}
@@ -31,7 +33,7 @@ extension WebChatContextCollector {
static func detectLinks(from messages: [ChatMessage]) -> [String] {
return messages.lazy
.compactMap {
- $0.content ?? $0.functionCall?.arguments
+ $0.content ?? $0.toolCalls?.map(\.function.arguments).joined(separator: " ") ?? ""
}
.map(detectLinks(from:))
.flatMap { $0 }
diff --git a/Core/Sources/ChatGPTChatTab/Chat.swift b/Core/Sources/ChatGPTChatTab/Chat.swift
new file mode 100644
index 00000000..28443876
--- /dev/null
+++ b/Core/Sources/ChatGPTChatTab/Chat.swift
@@ -0,0 +1,555 @@
+import AppKit
+import ChatBasic
+import ChatService
+import ComposableArchitecture
+import Foundation
+import MarkdownUI
+import OpenAIService
+import Preferences
+import Terminal
+
+public struct DisplayedChatMessage: Equatable {
+ public enum Role: Equatable {
+ case user
+ case assistant
+ case tool
+ case ignored
+ }
+
+ public struct Reference: Equatable {
+ public typealias Kind = ChatMessage.Reference.Kind
+
+ public var title: String
+ public var subtitle: String
+ public var uri: String
+ public var startLine: Int?
+ public var kind: Kind
+
+ public init(
+ title: String,
+ subtitle: String,
+ uri: String,
+ startLine: Int?,
+ kind: Kind
+ ) {
+ self.title = title
+ self.subtitle = subtitle
+ self.uri = uri
+ self.startLine = startLine
+ self.kind = kind
+ }
+ }
+
+ 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
+ }
+}
+
+private var isPreview: Bool {
+ ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
+}
+
+@Reducer
+struct Chat {
+ public typealias MessageID = String
+
+ @ObservableState
+ struct State: Equatable {
+ var title: String = "Chat"
+ var typedMessage = ""
+ var history: [DisplayedChatMessage] = []
+ var isReceivingMessage = false
+ var chatMenu = ChatMenu.State()
+ var focusedField: Field?
+ var isEnabled = true
+ var isPinnedToBottom = true
+
+ enum Field: String, Hashable {
+ case textField
+ }
+ }
+
+ enum Action: Equatable, BindableAction {
+ case binding(BindingAction)
+
+ case appear
+ case refresh
+ case setIsEnabled(Bool)
+ case sendButtonTapped
+ case returnButtonTapped
+ case stopRespondingButtonTapped
+ case clearButtonTap
+ case deleteMessageButtonTapped(MessageID)
+ case resendMessageButtonTapped(MessageID)
+ case setAsExtraPromptButtonTapped(MessageID)
+ case manuallyScrolledUp
+ case scrollToBottomButtonTapped
+ case focusOnTextField
+ case referenceClicked(DisplayedChatMessage.Reference)
+
+ case observeChatService
+ case observeHistoryChange
+ case observeIsReceivingMessageChange
+ case observeSystemPromptChange
+ case observeExtraSystemPromptChange
+ case observeDefaultScopesChange
+
+ case historyChanged
+ case isReceivingMessageChanged
+ case systemPromptChanged
+ case extraSystemPromptChanged
+ case defaultScopesChanged
+
+ case chatMenu(ChatMenu.Action)
+ }
+
+ let service: ChatService
+ let id = UUID()
+
+ enum CancelID: Hashable {
+ case observeHistoryChange(UUID)
+ case observeIsReceivingMessageChange(UUID)
+ case observeSystemPromptChange(UUID)
+ case observeExtraSystemPromptChange(UUID)
+ case observeDefaultScopesChange(UUID)
+ case sendMessage(UUID)
+ }
+
+ var body: some ReducerOf {
+ BindingReducer()
+
+ Scope(state: \.chatMenu, action: \.chatMenu) {
+ ChatMenu(service: service)
+ }
+
+ Reduce { state, action in
+ switch action {
+ case .appear:
+ return .run { send in
+ if isPreview { return }
+ await send(.observeChatService)
+ await send(.historyChanged)
+ await send(.isReceivingMessageChanged)
+ await send(.systemPromptChanged)
+ await send(.extraSystemPromptChanged)
+ await send(.focusOnTextField)
+ await send(.refresh)
+ }
+
+ case .refresh:
+ return .run { send in
+ 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
+ state.typedMessage = ""
+ return .run { _ in
+ try await service.send(content: message)
+ }.cancellable(id: CancelID.sendMessage(id))
+
+ case .returnButtonTapped:
+ state.typedMessage += "\n"
+ return .none
+
+ case .stopRespondingButtonTapped:
+ return .merge(
+ .run { _ in
+ await service.stopReceivingMessage()
+ },
+ .cancel(id: CancelID.sendMessage(id))
+ )
+
+ case .clearButtonTap:
+ return .run { _ in
+ await service.clearHistory()
+ }
+
+ case let .deleteMessageButtonTapped(id):
+ return .run { _ in
+ await service.deleteMessage(id: id)
+ }
+
+ case let .resendMessageButtonTapped(id):
+ return .run { _ in
+ try await service.resendMessage(id: id)
+ }
+
+ case let .setAsExtraPromptButtonTapped(id):
+ return .run { _ in
+ await service.setMessageAsExtraPrompt(id: id)
+ }
+
+ case let .referenceClicked(reference):
+ let fileURL = URL(fileURLWithPath: reference.uri)
+ return .run { _ in
+ if FileManager.default.fileExists(atPath: fileURL.path) {
+ let terminal = Terminal()
+ do {
+ _ = try await terminal.runCommand(
+ "/bin/bash",
+ arguments: [
+ "-c",
+ "xed -l \(reference.startLine ?? 0) ${TARGET_FILE}",
+ ],
+ environment: ["TARGET_FILE": reference.uri]
+ )
+ } catch {
+ print(error)
+ }
+ } else if let url = URL(string: reference.uri), url.scheme != nil {
+ 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
+
+ case .observeChatService:
+ return .run { send in
+ await send(.observeHistoryChange)
+ await send(.observeIsReceivingMessageChange)
+ await send(.observeSystemPromptChange)
+ await send(.observeExtraSystemPromptChange)
+ await send(.observeDefaultScopesChange)
+ }
+
+ case .observeHistoryChange:
+ return .run { send in
+ let stream = AsyncStream { continuation in
+ let cancellable = service.$chatHistory.sink { _ in
+ continuation.yield()
+ }
+ continuation.onTermination = { _ in
+ cancellable.cancel()
+ }
+ }
+ let debouncedHistoryChange = TimedDebounceFunction(duration: 0.2) {
+ await send(.historyChanged)
+ }
+
+ for await _ in stream {
+ await debouncedHistoryChange()
+ }
+ }.cancellable(id: CancelID.observeHistoryChange(id), cancelInFlight: true)
+
+ case .observeIsReceivingMessageChange:
+ return .run { send in
+ let stream = AsyncStream { continuation in
+ let cancellable = service.$isReceivingMessage
+ .sink { _ in
+ continuation.yield()
+ }
+ continuation.onTermination = { _ in
+ cancellable.cancel()
+ }
+ }
+ for await _ in stream {
+ await send(.isReceivingMessageChanged)
+ }
+ }.cancellable(
+ id: CancelID.observeIsReceivingMessageChange(id),
+ cancelInFlight: true
+ )
+
+ case .observeSystemPromptChange:
+ return .run { send in
+ let stream = AsyncStream { continuation in
+ let cancellable = service.$systemPrompt.sink { _ in
+ continuation.yield()
+ }
+ continuation.onTermination = { _ in
+ cancellable.cancel()
+ }
+ }
+ for await _ in stream {
+ await send(.systemPromptChanged)
+ }
+ }.cancellable(id: CancelID.observeSystemPromptChange(id), cancelInFlight: true)
+
+ case .observeExtraSystemPromptChange:
+ return .run { send in
+ let stream = AsyncStream { continuation in
+ let cancellable = service.$extraSystemPrompt
+ .sink { _ in
+ continuation.yield()
+ }
+ continuation.onTermination = { _ in
+ cancellable.cancel()
+ }
+ }
+ for await _ in stream {
+ await send(.extraSystemPromptChanged)
+ }
+ }.cancellable(id: CancelID.observeExtraSystemPromptChange(id), cancelInFlight: true)
+
+ case .observeDefaultScopesChange:
+ return .run { send in
+ let stream = AsyncStream { continuation in
+ let cancellable = service.$defaultScopes
+ .sink { _ in
+ continuation.yield()
+ }
+ continuation.onTermination = { _ in
+ cancellable.cancel()
+ }
+ }
+ for await _ in stream {
+ await send(.defaultScopesChanged)
+ }
+ }.cancellable(id: CancelID.observeDefaultScopesChange(id), cancelInFlight: true)
+
+ case .historyChanged:
+ state.history = service.chatHistory.flatMap { message in
+ var all = [DisplayedChatMessage]()
+ all.append(.init(
+ id: message.id,
+ role: {
+ switch message.role {
+ case .system: return .ignored
+ case .user: return .user
+ case .assistant:
+ if let text = message.summary ?? message.content,
+ !text.isEmpty
+ {
+ return .assistant
+ }
+ return .ignored
+ }
+ }(),
+ text: message.summary ?? message.content ?? "",
+ references: message.references.map(convertReference)
+ ))
+
+ for call in message.toolCalls ?? [] {
+ all.append(.init(
+ id: message.id + call.id,
+ role: .tool,
+ text: call.response.summary ?? call.response.content,
+ references: []
+ ))
+ }
+
+ return all
+ }
+
+ state.title = {
+ let defaultTitle = "Chat"
+ guard let lastMessageText = state.history
+ .filter({ $0.role == .assistant || $0.role == .user })
+ .last?
+ .text else { return defaultTitle }
+ if lastMessageText.isEmpty { return defaultTitle }
+ let trimmed = lastMessageText
+ .trimmingCharacters(in: .punctuationCharacters)
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ if trimmed.starts(with: "```") {
+ return "Code Block"
+ } else {
+ return trimmed
+ }
+ }()
+ return .none
+
+ case .isReceivingMessageChanged:
+ state.isReceivingMessage = service.isReceivingMessage
+ if service.isReceivingMessage {
+ state.isPinnedToBottom = true
+ }
+ return .none
+
+ case .systemPromptChanged:
+ state.chatMenu.systemPrompt = service.systemPrompt
+ return .none
+
+ case .extraSystemPromptChanged:
+ state.chatMenu.extraSystemPrompt = service.extraSystemPrompt
+ return .none
+
+ case .defaultScopesChanged:
+ state.chatMenu.defaultScopes = service.defaultScopes
+ return .none
+
+ case .binding:
+ return .none
+
+ case .chatMenu:
+ return .none
+ }
+ }
+ }
+}
+
+@Reducer
+struct ChatMenu {
+ @ObservableState
+ struct State: Equatable {
+ var systemPrompt: String = ""
+ var extraSystemPrompt: String = ""
+ var temperatureOverride: Double? = nil
+ var chatModelIdOverride: String? = nil
+ var defaultScopes: Set = []
+ }
+
+ enum Action: Equatable {
+ case appear
+ case refresh
+ case resetPromptButtonTapped
+ case temperatureOverrideSelected(Double?)
+ case chatModelIdOverrideSelected(String?)
+ case customCommandButtonTapped(CustomCommand)
+ case resetDefaultScopesButtonTapped
+ case toggleScope(ChatService.Scope)
+ }
+
+ let service: ChatService
+
+ var body: some ReducerOf {
+ Reduce { state, action in
+ switch action {
+ case .appear:
+ return .run {
+ await $0(.refresh)
+ }
+
+ case .refresh:
+ state.temperatureOverride = service.configuration.overriding.temperature
+ state.chatModelIdOverride = service.configuration.overriding.modelId
+ return .none
+
+ case .resetPromptButtonTapped:
+ return .run { _ in
+ await service.resetPrompt()
+ }
+ case let .temperatureOverrideSelected(temperature):
+ state.temperatureOverride = temperature
+ return .run { _ in
+ service.configuration.overriding.temperature = temperature
+ }
+ case let .chatModelIdOverrideSelected(chatModelId):
+ state.chatModelIdOverride = chatModelId
+ return .run { _ in
+ service.configuration.overriding.modelId = chatModelId
+ }
+ case let .customCommandButtonTapped(command):
+ return .run { _ in
+ try await service.handleCustomCommand(command)
+ }
+
+ case .resetDefaultScopesButtonTapped:
+ return .run { _ in
+ service.resetDefaultScopes()
+ }
+ case let .toggleScope(scope):
+ return .run { _ in
+ service.defaultScopes.formSymmetricDifference([scope])
+ }
+ }
+ }
+ }
+}
+
+private actor TimedDebounceFunction {
+ let duration: TimeInterval
+ let block: () async -> Void
+
+ var task: Task?
+ var lastFireTime: Date = .init(timeIntervalSince1970: 0)
+
+ init(duration: TimeInterval, block: @escaping () async -> Void) {
+ self.duration = duration
+ self.block = block
+ }
+
+ func callAsFunction() async {
+ task?.cancel()
+ if lastFireTime.timeIntervalSinceNow < -duration {
+ await fire()
+ task = nil
+ } else {
+ task = Task.detached { [weak self, duration] in
+ try await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000))
+ await self?.fire()
+ }
+ }
+ }
+
+ 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 ab9a2aa7..9114a5dd 100644
--- a/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift
+++ b/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift
@@ -1,42 +1,50 @@
import AppKit
+import ChatService
+import ComposableArchitecture
import SharedUIComponents
import SwiftUI
struct ChatTabItemView: View {
- @ObservedObject var chat: ChatProvider
+ let chat: StoreOf
var body: some View {
- Text(chat.title)
+ WithPerceptionTracking {
+ Text(chat.title)
+ }
}
}
struct ChatContextMenu: View {
- @ObservedObject var chat: ChatProvider
+ let store: StoreOf
@AppStorage(\.customCommands) var customCommands
@AppStorage(\.chatModels) var chatModels
@AppStorage(\.defaultChatFeatureChatModelId) var defaultChatModelId
@AppStorage(\.chatGPTTemperature) var defaultTemperature
var body: some View {
- currentSystemPrompt
- currentExtraSystemPrompt
- resetPrompt
+ WithPerceptionTracking {
+ currentSystemPrompt
+ .onAppear { store.send(.appear) }
+ currentExtraSystemPrompt
+ resetPrompt
- Divider()
+ Divider()
- chatModel
- temperature
+ chatModel
+ temperature
+ defaultScopes
- Divider()
+ Divider()
- customCommandMenu
+ customCommandMenu
+ }
}
@ViewBuilder
var currentSystemPrompt: some View {
Text("System Prompt:")
Text({
- var text = chat.systemPrompt
+ var text = store.systemPrompt
if text.isEmpty { text = "N/A" }
if text.count > 30 { text = String(text.prefix(30)) + "..." }
return text
@@ -47,7 +55,7 @@ struct ChatContextMenu: View {
var currentExtraSystemPrompt: some View {
Text("Extra Prompt:")
Text({
- var text = chat.extraSystemPrompt
+ var text = store.extraSystemPrompt
if text.isEmpty { text = "N/A" }
if text.count > 30 { text = String(text.prefix(30)) + "..." }
return text
@@ -56,20 +64,29 @@ struct ChatContextMenu: View {
var resetPrompt: some View {
Button("Reset System Prompt") {
- chat.resetPrompt()
+ store.send(.resetPromptButtonTapped)
}
}
@ViewBuilder
var chatModel: some View {
+ let allModels = chatModels + [.init(
+ id: "com.github.copilot",
+ name: "GitHub Copilot Language Server",
+ format: .openAI,
+ info: .init()
+ )]
+
Menu("Chat Model") {
Button(action: {
- chat.chatModelId = nil
+ store.send(.chatModelIdOverrideSelected(nil))
}) {
HStack {
- if let defaultModel = chatModels.first(where: { $0.id == defaultChatModelId }) {
+ if let defaultModel = allModels
+ .first(where: { $0.id == defaultChatModelId })
+ {
Text("Default (\(defaultModel.name))")
- if chat.chatModelId == nil {
+ if store.chatModelIdOverride == nil {
Image(systemName: "checkmark")
}
} else {
@@ -78,12 +95,9 @@ struct ChatContextMenu: View {
}
}
- if let id = chat.chatModelId,
- !chatModels.map(\.id).contains(id)
- {
+ if let id = store.chatModelIdOverride, !allModels.map(\.id).contains(id) {
Button(action: {
- chat.chatModelId = nil
- chat.objectWillChange.send()
+ store.send(.chatModelIdOverrideSelected(nil))
}) {
HStack {
Text("Default (Selected Model Not Found)")
@@ -91,17 +105,16 @@ struct ChatContextMenu: View {
}
}
}
-
+
Divider()
- ForEach(chatModels, id: \.id) { model in
+ ForEach(allModels, id: \.id) { model in
Button(action: {
- chat.chatModelId = model.id
- chat.objectWillChange.send()
+ store.send(.chatModelIdOverrideSelected(model.id))
}) {
HStack {
Text(model.name)
- if model.id == chat.chatModelId {
+ if model.id == store.chatModelIdOverride {
Image(systemName: "checkmark")
}
}
@@ -114,27 +127,53 @@ struct ChatContextMenu: View {
var temperature: some View {
Menu("Temperature") {
Button(action: {
- chat.temperature = nil
+ store.send(.temperatureOverrideSelected(nil))
}) {
HStack {
Text(
"Default (\(defaultTemperature.formatted(.number.precision(.fractionLength(1)))))"
)
- if chat.temperature == nil {
+ 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: {
- chat.temperature = value
+ store.send(.temperatureOverrideSelected(value))
}) {
HStack {
Text("\(value.formatted(.number.precision(.fractionLength(1))))")
- if value == chat.temperature {
+ if value == store.temperatureOverride {
+ Image(systemName: "checkmark")
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ var defaultScopes: some View {
+ Menu("Default Scopes") {
+ Button(action: {
+ store.send(.resetDefaultScopesButtonTapped)
+ }) {
+ Text("Reset Default Scopes")
+ }
+
+ Divider()
+
+ ForEach(ChatService.Scope.allCases, id: \.rawValue) { value in
+ Button(action: {
+ store.send(.toggleScope(value))
+ }) {
+ HStack {
+ Text("@" + value.rawValue)
+ if store.defaultScopes.contains(value) {
Image(systemName: "checkmark")
}
}
@@ -156,7 +195,7 @@ struct ChatContextMenu: View {
id: \.name
) { command in
Button(action: {
- chat.triggerCustomCommand(command)
+ store.send(.customCommandButtonTapped(command))
}) {
Text(command.name)
}
diff --git a/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift b/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift
index 17532b24..ad2c6887 100644
--- a/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift
+++ b/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift
@@ -1,7 +1,10 @@
+import ChatContextCollector
import ChatService
import ChatTab
+import CodableWrappers
import Combine
import ComposableArchitecture
+import DebounceFunction
import Foundation
import OpenAIService
import Preferences
@@ -12,14 +15,17 @@ public class ChatGPTChatTab: ChatTab {
public static var name: String { "Chat" }
public let service: ChatService
- public let provider: ChatProvider
+ let chat: StoreOf
private var cancellable = Set()
+ private var observer = NSObject()
+ private let updateContentDebounce = DebounceRunner(duration: 0.5)
struct RestorableState: Codable {
var history: [OpenAIService.ChatMessage]
var configuration: OverridingChatGPTConfiguration.Overriding
var systemPrompt: String
var extraSystemPrompt: String
+ var defaultScopes: Set?
}
struct Builder: ChatTabBuilder {
@@ -28,7 +34,7 @@ public class ChatGPTChatTab: ChatTab {
var afterBuild: (ChatGPTChatTab) async -> Void = { _ in }
func build(store: StoreOf) async -> (any ChatTab)? {
- let tab = ChatGPTChatTab(store: store)
+ let tab = await ChatGPTChatTab(store: store)
if let customCommand {
try? await tab.service.handleCustomCommand(customCommand)
}
@@ -38,15 +44,25 @@ public class ChatGPTChatTab: ChatTab {
}
public func buildView() -> any View {
- ChatPanel(chat: provider)
+ ChatPanel(chat: chat)
}
public func buildTabItem() -> any View {
- ChatTabItemView(chat: provider)
+ ChatTabItemView(chat: chat)
+ }
+
+ public func buildIcon() -> any View {
+ WithPerceptionTracking {
+ if self.chat.isReceivingMessage {
+ Image(systemName: "ellipsis.message")
+ } else {
+ Image(systemName: "message")
+ }
+ }
}
public func buildMenu() -> any View {
- ChatContextMenu(chat: provider)
+ ChatContextMenu(store: chat.scope(state: \.chatMenu, action: \.chatMenu))
}
public func restorableState() async -> Data {
@@ -54,28 +70,30 @@ public class ChatGPTChatTab: ChatTab {
history: await service.memory.history,
configuration: service.configuration.overriding,
systemPrompt: service.systemPrompt,
- extraSystemPrompt: service.extraSystemPrompt
+ extraSystemPrompt: service.extraSystemPrompt,
+ defaultScopes: service.defaultScopes
)
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
tab.service.mutateSystemPrompt(state.systemPrompt)
tab.service.mutateExtraSystemPrompt(state.extraSystemPrompt)
+ if let scopes = state.defaultScopes {
+ tab.service.defaultScopes = scopes
+ }
await tab.service.memory.mutateHistory { history in
history = state.history
}
+ 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 {
@@ -84,130 +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
- provider = .init(service: service)
+ chat = .init(initialState: .init(), reducer: { Chat(service: service) })
super.init(store: store)
}
public func start() {
- chatTabViewStore.send(.updateTitle("Chat"))
+ observer = .init()
+ cancellable = []
- service.$systemPrompt.removeDuplicates().sink { _ in
- Task { @MainActor [weak self] in
- self?.chatTabViewStore.send(.tabContentUpdated)
- }
- }.store(in: &cancellable)
+ chatTabStore.send(.updateTitle("Chat"))
- service.$extraSystemPrompt.removeDuplicates().sink { _ in
+ service.$systemPrompt.removeDuplicates().sink { [weak self] _ in
Task { @MainActor [weak self] in
- self?.chatTabViewStore.send(.tabContentUpdated)
+ self?.chatTabStore.send(.tabContentUpdated)
}
}.store(in: &cancellable)
- provider.$history.sink { [weak self] _ in
+ service.$extraSystemPrompt.removeDuplicates().sink { [weak self] _ in
Task { @MainActor [weak self] in
- if let title = self?.provider.title {
- self?.chatTabViewStore.send(.updateTitle(title))
- }
+ self?.chatTabStore.send(.tabContentUpdated)
}
}.store(in: &cancellable)
- provider.objectWillChange.debounce(for: .seconds(1), scheduler: DispatchQueue.main)
- .sink { [weak self] _ in
+ 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?.chatTabViewStore.send(.tabContentUpdated)
+ self?.chat.send(.focusOnTextField)
}
- }.store(in: &cancellable)
- }
-}
-
-extension ChatProvider {
- convenience init(service: ChatService) {
- self.init(
- configuration: service.configuration,
- pluginIdentifiers: service.allPluginCommands
- )
-
- let cancellable = service.objectWillChange.sink { [weak self] in
- guard let self else { return }
- Task { @MainActor in
- self.history = (await service.memory.history).map { message in
- .init(
- id: message.id,
- role: {
- switch message.role {
- case .system: return .ignored
- case .user: return .user
- case .assistant:
- if let text = message.summary ?? message.content, !text.isEmpty {
- return .assistant
- }
- return .ignored
- case .function: return .function
- }
- }(),
- text: message.summary ?? message.content ?? ""
- )
- }
- self.isReceivingMessage = service.isReceivingMessage
- self.systemPrompt = service.systemPrompt
- self.extraSystemPrompt = service.extraSystemPrompt
- }
- }
-
- service.objectWillChange.send()
-
- onMessageSend = { [cancellable] message in
- _ = cancellable
- Task {
- try await service.send(content: message)
- }
- }
- onStop = {
- Task {
- await service.stopReceivingMessage()
}
}
- onClear = {
- Task {
- await service.clearHistory()
- }
- }
-
- onDeleteMessage = { id in
- Task {
- await service.deleteMessage(id: id)
- }
- }
-
- onResendMessage = { id in
- Task {
- try await service.resendMessage(id: id)
- }
- }
-
- onResetPrompt = {
- Task {
- await service.resetPrompt()
+ 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))
+ }
}
}
- onRunCustomCommand = { command in
- Task {
- try await service.handleCustomCommand(command)
+ 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)
+ }
+ }
}
}
+ }
- onSetAsExtraPrompt = { id in
- Task {
- await service.setMessageAsExtraPrompt(id: id)
+ 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 ad537aa4..9210a05d 100644
--- a/Core/Sources/ChatGPTChatTab/ChatPanel.swift
+++ b/Core/Sources/ChatGPTChatTab/ChatPanel.swift
@@ -1,335 +1,382 @@
import AppKit
-import OpenAIService
+import Combine
+import ComposableArchitecture
import MarkdownUI
+import OpenAIService
import SharedUIComponents
import SwiftUI
private let r: Double = 8
public struct ChatPanel: View {
- @ObservedObject var chat: ChatProvider
+ let chat: StoreOf
@Namespace var inputAreaNamespace
- @State var typedMessage = ""
-
- public init(chat: ChatProvider, typedMessage: String = "") {
- self.chat = chat
- self.typedMessage = typedMessage
- }
public var body: some View {
VStack(spacing: 0) {
- ChatPanelMessages(
- chat: chat
- )
+ ChatPanelMessages(chat: chat)
Divider()
- ChatPanelInputArea(
- chat: chat,
- typedMessage: $typedMessage
- )
+ ChatPanelInputArea(chat: chat)
}
- .background(.regularMaterial)
+ .background(Color(nsColor: .windowBackgroundColor))
+ .onAppear { chat.send(.appear) }
}
}
-struct ChatPanelMessages: View {
- @ObservedObject var chat: ChatProvider
-
- var body: some View {
- List {
- Group {
- Spacer()
-
- if chat.isReceivingMessage {
- StopRespondingButton(chat: chat)
- .padding(.vertical, 4)
- .listRowInsets(EdgeInsets(top: 0, leading: -8, bottom: 0, trailing: -8))
- }
+private struct ScrollViewOffsetPreferenceKey: PreferenceKey {
+ static var defaultValue = CGFloat.zero
- ForEach(chat.history.reversed(), 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, chat: chat)
- .listRowInsets(EdgeInsets(top: 0, leading: -8, bottom: 0, trailing: -8))
- .padding(.vertical, 4)
- case .function:
- FunctionMessage(id: message.id, text: text)
- case .ignored:
- EmptyView()
- }
- }
- .listItemTint(.clear)
+ static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
+ value += nextValue()
+ }
+}
- Instruction()
+private struct ListHeightPreferenceKey: PreferenceKey {
+ static var defaultValue = CGFloat.zero
- Spacer()
- }
- .scaleEffect(x: -1, y: 1, anchor: .center)
- }
- .id("\(chat.history.count), \(chat.isReceivingMessage)")
- .listStyle(.plain)
- .scaleEffect(x: 1, y: -1, anchor: .center)
+ static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
+ value += nextValue()
}
}
-private struct StopRespondingButton: View {
- let chat: ChatProvider
+struct ChatPanelMessages: View {
+ let chat: StoreOf
+ @State var cancellable = Set()
+ @State var isScrollToBottomButtonDisplayed = 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 {
- Button(action: {
- chat.stop()
- }) {
- HStack(spacing: 4) {
- Image(systemName: "stop.fill")
- Text("Stop Responding")
+ 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
+ )
+ })
+ }
+ .modify { view in
+ if #available(macOS 13.0, *) {
+ view
+ .listRowSeparator(.hidden)
+ .listSectionSeparator(.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()
+ }
+ .onPreferenceChange(ScrollViewOffsetPreferenceKey.self) { value in
+ scrollOffset = value
+ updatePinningState()
+ }
+ .overlay(alignment: .bottom) {
+ StopRespondingButton(chat: chat)
+ }
+ .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)
+ }
+ }
}
- .padding(8)
- .background(
- .regularMaterial,
- in: RoundedRectangle(cornerRadius: r, style: .continuous)
- )
- .overlay {
- RoundedRectangle(cornerRadius: r, style: .continuous)
- .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
+ .onAppear {
+ trackScrollWheel()
+ }
+ .onDisappear {
+ cancellable.forEach { $0.cancel() }
+ cancellable = []
+ }
+ .onChange(of: isEnabled) { isEnabled in
+ chat.send(.setIsEnabled(isEnabled))
}
}
- .buttonStyle(.borderless)
- .scaleEffect(x: -1, y: -1, anchor: .center)
- .frame(maxWidth: .infinity, alignment: .center)
}
-}
-
-private struct Instruction: View {
- @AppStorage(\.useCodeScopeByDefaultInChatContext)
- var useCodeScopeByDefaultInChatContext
-
- var body: some View {
- Group {
- Markdown(
- """
- Hello, I am your AI programming assistant. I can identify issues, explain and even improve code.
-
- \(
- useCodeScopeByDefaultInChatContext
- ? "Scope **`@code`** is enabled by default."
- : "Scope **`@file`** is enabled by default."
- )
- """
- )
- .modifier(InstructionModifier())
-
- Markdown(
- """
- You can use scopes to give the bot extra abilities.
- | Scope Name | Abilities |
- | --- | --- |
- | `@file` | Read the metadata of the editing file |
- | `@code` | Read the code and metadata in the editing file |
- | `@web` (beta) | Search on Bing or query from a web page |
- | `@project` | Experimental. Access content of the project |
-
- To use scopes, you can prefix a message with `@code`.
+ func trackScrollWheel() {
+ NSApplication.shared.publisher(for: \.currentEvent)
+ .receive(on: DispatchQueue.main)
+ .filter { [chat] in
+ guard chat.withState(\.isEnabled) else { return false }
+ return $0?.type == .scrollWheel
+ }
+ .compactMap { $0 }
+ .sink { event in
+ guard chat.withState(\.isPinnedToBottom) else { return }
+ let delta = event.deltaY
+ let scrollUp = delta > 0
+ if scrollUp {
+ chat.send(.manuallyScrolledUp)
+ }
+ }
+ .store(in: &cancellable)
+ }
- You can use shorthand to represent a scope, such as `@c`, and enable multiple scopes with `@c+web`.
- """
- )
- .modifier(InstructionModifier())
-
- 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 |
- | `/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())
+ @MainActor
+ func updatePinningState() {
+ // where does the 32 come from?
+ withAnimation(.linear(duration: 0.1)) {
+ isScrollToBottomButtonDisplayed = scrollOffset > listHeight + 32 + 20
+ || scrollOffset <= 0
}
}
- struct InstructionModifier: ViewModifier {
- @AppStorage(\.chatFontSize) var chatFontSize
-
- func body(content: Content) -> some View {
- content
- .textSelection(.enabled)
- .markdownTheme(.custom(fontSize: chatFontSize))
- .opacity(0.8)
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding()
+ @ViewBuilder
+ func scrollToBottomButton(proxy: ScrollViewProxy) -> some View {
+ Button(action: {
+ chat.send(.scrollToBottomButtonTapped)
+ withAnimation(.easeInOut(duration: 0.1)) {
+ proxy.scrollTo(bottomID, anchor: .bottom)
+ }
+ }) {
+ Image(systemName: "arrow.down")
+ .padding(4)
+ .background {
+ Circle()
+ .fill(.thickMaterial)
+ .shadow(color: .black.opacity(0.2), radius: 2)
+ }
.overlay {
- RoundedRectangle(cornerRadius: 8)
- .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
+ Circle().stroke(Color(nsColor: .separatorColor), lineWidth: 1)
}
- .scaleEffect(x: -1, y: -1, anchor: .center)
+ .foregroundStyle(.secondary)
+ .padding(4)
}
+ .keyboardShortcut(.downArrow, modifiers: [.command])
+ .opacity(isScrollToBottomButtonDisplayed ? 1 : 0)
+ .buttonStyle(.plain)
}
-}
-private struct UserMessage: View {
- let id: String
- let text: String
- let chat: ChatProvider
- @Environment(\.colorScheme) var colorScheme
- @AppStorage(\.chatFontSize) var chatFontSize
- @AppStorage(\.chatCodeFontSize) var chatCodeFontSize
+ struct ExtraSpacingInResponding: View {
+ let chat: StoreOf
- var body: some View {
- Markdown(text)
- .textSelection(.enabled)
- .markdownTheme(.custom(fontSize: chatFontSize))
- .markdownCodeSyntaxHighlighter(
- ChatCodeSyntaxHighlighter(
- brightMode: colorScheme != .dark,
- fontSize: chatCodeFontSize
- )
- )
- .frame(alignment: .leading)
- .padding()
- .background {
- RoundedCorners(tl: r, tr: r, bl: r, br: 0)
- .fill(Color.userChatContentBackground)
- }
- .overlay {
- RoundedCorners(tl: r, tr: r, bl: r, br: 0)
- .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
- }
- .padding(.leading)
- .padding(.trailing, 8)
- .scaleEffect(x: -1, y: -1, anchor: .center)
- .shadow(color: .black.opacity(0.1), radius: 2)
- .frame(maxWidth: .infinity, alignment: .leading)
- .contextMenu {
- Button("Copy") {
- NSPasteboard.general.clearContents()
- NSPasteboard.general.setString(text, forType: .string)
- }
-
- Button("Send Again") {
- chat.resendMessage(id: id)
+ var body: some View {
+ WithPerceptionTracking {
+ if chat.isReceivingMessage {
+ Spacer(minLength: 12)
}
+ }
+ }
+ }
- Button("Set as Extra System Prompt") {
- chat.setAsExtraPrompt(id: id)
- }
+ struct PinToBottomHandler: View {
+ let chat: StoreOf
+ let isBottomHidden: Bool
+ let scrollToBottom: () -> Void
- Divider()
+ @State var isInitialLoad = true
- Button("Delete") {
- chat.deleteMessage(id: id)
- }
+ var body: some View {
+ WithPerceptionTracking {
+ EmptyView()
+ .onChange(of: chat.isReceivingMessage) { isReceiving in
+ if isReceiving {
+ Task {
+ await Task.yield()
+ withAnimation(.easeInOut(duration: 0.1)) {
+ scrollToBottom()
+ }
+ }
+ }
+ }
+ .onChange(of: chat.history.last) { _ in
+ if chat.withState(\.isPinnedToBottom) || isInitialLoad {
+ if isInitialLoad {
+ isInitialLoad = false
+ }
+ Task {
+ await Task.yield()
+ 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()
+ }
+ }
}
+ }
}
}
-private struct BotMessage: View {
- let id: String
- let text: String
- let chat: ChatProvider
- @Environment(\.colorScheme) var colorScheme
- @AppStorage(\.chatFontSize) var chatFontSize
- @AppStorage(\.chatCodeFontSize) var chatCodeFontSize
+struct ChatHistory: View {
+ let chat: StoreOf
var body: some View {
- HStack(alignment: .bottom, spacing: 2) {
- CopyButton {
- NSPasteboard.general.clearContents()
- NSPasteboard.general.setString(text, forType: .string)
- }
- .scaleEffect(x: -1, y: -1, anchor: .center)
-
- Markdown(text)
- .textSelection(.enabled)
- .markdownTheme(.custom(fontSize: chatFontSize))
- .markdownCodeSyntaxHighlighter(
- ChatCodeSyntaxHighlighter(
- brightMode: colorScheme != .dark,
- fontSize: chatCodeFontSize
- )
- )
- .frame(alignment: .trailing)
- .padding()
- .background {
- RoundedCorners(tl: r, tr: r, bl: 0, br: r)
- .fill(Color.contentBackground)
+ WithPerceptionTracking {
+ ForEach(chat.history, id: \.id) { message in
+ WithPerceptionTracking {
+ ChatHistoryItem(chat: chat, message: message).id(message.id)
}
- .overlay {
- RoundedCorners(tl: r, tr: r, bl: 0, br: r)
- .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
- }
- .padding(.leading, 8)
- .scaleEffect(x: -1, y: -1, anchor: .center)
- .shadow(color: .black.opacity(0.1), radius: 2)
- .contextMenu {
- Button("Copy") {
- NSPasteboard.general.clearContents()
- NSPasteboard.general.setString(text, forType: .string)
- }
-
- Button("Set as Extra System Prompt") {
- chat.setAsExtraPrompt(id: id)
- }
+ }
+ }
+ }
+}
- Divider()
+struct ChatHistoryItem: View {
+ let chat: StoreOf
+ let message: DisplayedChatMessage
- Button("Delete") {
- chat.deleteMessage(id: id)
- }
- }
+ var body: some View {
+ 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()
+ }
}
- .frame(maxWidth: .infinity, alignment: .trailing)
- .padding(.trailing, 2)
}
}
-struct FunctionMessage: View {
- let id: String
- let text: String
- @AppStorage(\.chatFontSize) var chatFontSize
+private struct StopRespondingButton: View {
+ let chat: StoreOf
var body: some View {
- Markdown(text)
- .textSelection(.enabled)
- .markdownTheme(.functionCall(fontSize: chatFontSize))
- .scaleEffect(x: -1, y: -1, anchor: .center)
- .padding(.vertical, 2)
- .padding(.trailing, 2)
+ 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
+ ))
+ }
+ }
}
}
struct ChatPanelInputArea: View {
- @ObservedObject var chat: ChatProvider
- @Binding var typedMessage: String
- @FocusState var isInputAreaFocused: Bool
+ let chat: StoreOf
+ @FocusState var focusedField: Chat.State.Field?
var body: some View {
HStack {
clearButton
- textEditor
- }
- .onAppear {
- isInputAreaFocused = true
+ InputAreaTextEditor(chat: chat, focusedField: $focusedField)
}
.padding(8)
.background(.ultraThickMaterial)
}
+ @MainActor
var clearButton: some View {
Button(action: {
- chat.clear()
+ chat.send(.clearButtonTap)
}) {
Group {
if #available(macOS 13.0, *) {
@@ -343,190 +390,91 @@ struct ChatPanelInputArea: View {
Circle().fill(Color(nsColor: .controlBackgroundColor))
}
.overlay {
- Circle()
- .stroke(Color(nsColor: .controlColor), lineWidth: 1)
+ Circle().stroke(Color(nsColor: .controlColor), lineWidth: 1)
}
}
.buttonStyle(.plain)
}
- var textEditor: some View {
- HStack(spacing: 0) {
- ZStack(alignment: .center) {
- // a hack to support dynamic height of TextEditor
- Text(typedMessage.isEmpty ? "Hi" : typedMessage).opacity(0)
- .font(.system(size: 14))
- .frame(maxWidth: .infinity, maxHeight: 400)
- .padding(.top, 1)
- .padding(.bottom, 2)
- .padding(.horizontal, 4)
-
- CustomTextEditor(
- text: $typedMessage,
- font: .systemFont(ofSize: 14),
- onSubmit: { submitText() },
- completions: chatAutoCompletion
- )
- .padding(.top, 1)
- .padding(.bottom, -1)
- }
- .focused($isInputAreaFocused)
- .padding(8)
- .fixedSize(horizontal: false, vertical: true)
-
- Button(action: {
- submitText()
- }) {
- Image(systemName: "paperplane.fill")
+ 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)
- }
- .buttonStyle(.plain)
- .disabled(chat.isReceivingMessage)
- .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: {
- typedMessage += "\n"
- }) {
- EmptyView()
- }
- .keyboardShortcut(KeyEquivalent.return, modifiers: [.shift])
-
- Button(action: {
- isInputAreaFocused = true
- }) {
- EmptyView()
- }
- .keyboardShortcut("l", modifiers: [.command])
- }
- }
+ .fixedSize(horizontal: false, vertical: true)
- func submitText() {
- if typedMessage.isEmpty { return }
- chat.send(typedMessage)
- typedMessage = ""
- }
+ Button(action: {
+ chat.send(.sendButtonTapped)
+ }) {
+ Image(systemName: "paperplane.fill")
+ .padding(8)
+ }
+ .buttonStyle(.plain)
+ .disabled(chat.isReceivingMessage)
+ .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])
- func chatAutoCompletion(text: String, proposed: [String], range: NSRange) -> [String] {
- guard text.count == 1 else { return [] }
- let plugins = chat.pluginIdentifiers.map { "/\($0)" }
- let availableFeatures = plugins + [
- "/exit",
- "@code",
- "@file",
- "@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...])
+ Button(action: {
+ focusedField.wrappedValue = .textField
+ }) {
+ EmptyView()
+ }
+ .keyboardShortcut("l", modifiers: [.command])
+ }
}
- return result
- }
-}
-
-struct RoundedCorners: Shape {
- var tl: CGFloat = 0.0
- var tr: CGFloat = 0.0
- var bl: CGFloat = 0.0
- var br: CGFloat = 0.0
-
- func path(in rect: CGRect) -> Path {
- Path { path in
-
- let w = rect.size.width
- let h = rect.size.height
-
- // Make sure we do not exceed the size of the rectangle
- let tr = min(min(self.tr, h / 2), w / 2)
- let tl = min(min(self.tl, h / 2), w / 2)
- let bl = min(min(self.bl, h / 2), w / 2)
- let br = min(min(self.br, h / 2), w / 2)
-
- path.move(to: CGPoint(x: w / 2.0, y: 0))
- path.addLine(to: CGPoint(x: w - tr, y: 0))
- path.addArc(
- center: CGPoint(x: w - tr, y: tr),
- radius: tr,
- startAngle: Angle(degrees: -90),
- endAngle: Angle(degrees: 0),
- clockwise: false
- )
- path.addLine(to: CGPoint(x: w, y: h - br))
- path.addArc(
- center: CGPoint(x: w - br, y: h - br),
- radius: br,
- startAngle: Angle(degrees: 0),
- endAngle: Angle(degrees: 90),
- clockwise: false
- )
- path.addLine(to: CGPoint(x: bl, y: h))
- path.addArc(
- center: CGPoint(x: bl, y: h - bl),
- radius: bl,
- startAngle: Angle(degrees: 90),
- endAngle: Angle(degrees: 180),
- clockwise: false
- )
- path.addLine(to: CGPoint(x: 0, y: tl))
- path.addArc(
- center: CGPoint(x: tl, y: tl),
- radius: tl,
- startAngle: Angle(degrees: 180),
- endAngle: Angle(degrees: 270),
- clockwise: false
- )
- path.closeSubpath()
}
- }
-}
-
-struct GlobalChatSwitchToggleStyle: ToggleStyle {
- func makeBody(configuration: Configuration) -> some View {
- HStack(spacing: 4) {
- Text(configuration.isOn ? "Shared Conversation" : "Local Conversation")
- .foregroundStyle(.tertiary)
- RoundedRectangle(cornerRadius: 10, style: .circular)
- .foregroundColor(configuration.isOn ? Color.indigo : .gray.opacity(0.5))
- .frame(width: 30, height: 20, alignment: .center)
- .overlay(
- Circle()
- .fill(.regularMaterial)
- .padding(.all, 2)
- .overlay(
- Image(
- systemName: configuration
- .isOn ? "globe" : "doc.circle"
- )
- .resizable()
- .aspectRatio(contentMode: .fit)
- .frame(width: 12, height: 12, alignment: .center)
- .foregroundStyle(.secondary)
- )
- .offset(x: configuration.isOn ? 5 : -5, y: 0)
- .animation(.linear(duration: 0.1), value: configuration.isOn)
- )
- .onTapGesture { configuration.isOn.toggle() }
- .overlay {
- RoundedRectangle(cornerRadius: 10, style: .circular)
- .stroke(.black.opacity(0.2), lineWidth: 1)
+ 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
}
}
}
@@ -534,11 +482,12 @@ struct GlobalChatSwitchToggleStyle: ToggleStyle {
// MARK: - Previews
struct ChatPanel_Preview: PreviewProvider {
- static let history: [ChatMessage] = [
+ static let history: [DisplayedChatMessage] = [
.init(
id: "1",
role: .user,
- text: "**Hello**"
+ text: "**Hello**",
+ references: []
),
.init(
id: "2",
@@ -548,18 +497,47 @@ struct ChatPanel_Preview: PreviewProvider {
func foo() {}
```
**Hey**! What can I do for you?**Hey**! What can I do for you?**Hey**! What can I do for you?**Hey**! What can I do for you?
- """
+ """,
+ references: [
+ .init(
+ title: "Hello Hello Hello Hello",
+ subtitle: "Hi Hi Hi Hi",
+ uri: "https://google.com",
+ startLine: nil,
+ kind: .symbol(.class, uri: "https://google.com", startLine: nil, endLine: nil)
+ ),
+ ]
+ ),
+ .init(
+ id: "7",
+ role: .ignored,
+ text: "Ignored",
+ references: []
+ ),
+ .init(
+ id: "6",
+ role: .tool,
+ text: """
+ Searching for something...
+ - abc
+ - [def](https://1.com)
+ > hello
+ > hi
+ """,
+ references: []
+ ),
+ .init(
+ id: "5",
+ role: .assistant,
+ text: "Yooo",
+ references: []
+ ),
+ .init(
+ id: "4",
+ role: .user,
+ text: "Yeeeehh",
+ references: []
),
- .init(id: "7", role: .ignored, text: "Ignored"),
- .init(id: "6", role: .function, text: """
- Searching for something...
- - abc
- - [def](https://1.com)
- > hello
- > hi
- """),
- .init(id: "5", role: .assistant, text: "Yooo"),
- .init(id: "4", role: .user, text: "Yeeeehh"),
.init(
id: "3",
role: .user,
@@ -576,15 +554,15 @@ struct ChatPanel_Preview: PreviewProvider {
```objectivec
- (void)bar {}
```
- """#
+ """#,
+ references: []
),
]
static var previews: some View {
ChatPanel(chat: .init(
- configuration: UserPreferenceChatGPTConfiguration().overriding(.init()),
- history: ChatPanel_Preview.history,
- isReceivingMessage: true
+ initialState: .init(history: ChatPanel_Preview.history, isReceivingMessage: true),
+ reducer: { Chat(service: .init()) }
))
.frame(width: 450, height: 1200)
.colorScheme(.dark)
@@ -594,9 +572,8 @@ struct ChatPanel_Preview: PreviewProvider {
struct ChatPanel_EmptyChat_Preview: PreviewProvider {
static var previews: some View {
ChatPanel(chat: .init(
- configuration: UserPreferenceChatGPTConfiguration().overriding(.init()),
- history: [],
- isReceivingMessage: false
+ initialState: .init(history: [DisplayedChatMessage](), isReceivingMessage: false),
+ reducer: { Chat(service: .init()) }
))
.padding()
.frame(width: 450, height: 600)
@@ -604,32 +581,11 @@ struct ChatPanel_EmptyChat_Preview: PreviewProvider {
}
}
-struct ChatCodeSyntaxHighlighter: CodeSyntaxHighlighter {
- let brightMode: Bool
- let fontSize: Double
-
- init(brightMode: Bool, fontSize: Double) {
- self.brightMode = brightMode
- self.fontSize = fontSize
- }
-
- func highlightCode(_ content: String, language: String?) -> Text {
- let content = highlightedCodeBlock(
- code: content,
- language: language ?? "",
- brightMode: brightMode,
- fontSize: fontSize
- )
- return Text(AttributedString(content))
- }
-}
-
struct ChatPanel_InputText_Preview: PreviewProvider {
static var previews: some View {
ChatPanel(chat: .init(
- configuration: UserPreferenceChatGPTConfiguration().overriding(.init()),
- history: ChatPanel_Preview.history,
- isReceivingMessage: false
+ initialState: .init(history: ChatPanel_Preview.history, isReceivingMessage: false),
+ reducer: { Chat(service: .init()) }
))
.padding()
.frame(width: 450, height: 600)
@@ -641,11 +597,14 @@ struct ChatPanel_InputMultilineText_Preview: PreviewProvider {
static var previews: some View {
ChatPanel(
chat: .init(
- configuration: UserPreferenceChatGPTConfiguration().overriding(.init()),
- history: ChatPanel_Preview.history,
- isReceivingMessage: false
- ),
- typedMessage: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce turpis dolor, malesuada quis fringilla sit amet, placerat at nunc. Suspendisse orci tortor, tempor nec blandit a, malesuada vel tellus. Nunc sed leo ligula. Ut at ligula eget turpis pharetra tristique. Integer luctus leo non elit rhoncus fermentum."
+ initialState: .init(
+ typedMessage: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce turpis dolor, malesuada quis fringilla sit amet, placerat at nunc. Suspendisse orci tortor, tempor nec blandit a, malesuada vel tellus. Nunc sed leo ligula. Ut at ligula eget turpis pharetra tristique. Integer luctus leo non elit rhoncus fermentum.",
+
+ history: ChatPanel_Preview.history,
+ isReceivingMessage: false
+ ),
+ reducer: { Chat(service: .init()) }
+ )
)
.padding()
.frame(width: 450, height: 600)
@@ -656,9 +615,8 @@ struct ChatPanel_InputMultilineText_Preview: PreviewProvider {
struct ChatPanel_Light_Preview: PreviewProvider {
static var previews: some View {
ChatPanel(chat: .init(
- configuration: UserPreferenceChatGPTConfiguration().overriding(.init()),
- history: ChatPanel_Preview.history,
- isReceivingMessage: true
+ initialState: .init(history: ChatPanel_Preview.history, isReceivingMessage: true),
+ reducer: { Chat(service: .init()) }
))
.padding()
.frame(width: 450, height: 600)
diff --git a/Core/Sources/ChatGPTChatTab/ChatProvider.swift b/Core/Sources/ChatGPTChatTab/ChatProvider.swift
deleted file mode 100644
index cce47476..00000000
--- a/Core/Sources/ChatGPTChatTab/ChatProvider.swift
+++ /dev/null
@@ -1,119 +0,0 @@
-import Foundation
-import OpenAIService
-import Preferences
-import SwiftUI
-
-public final class ChatProvider: ObservableObject {
- public typealias MessageID = String
- public let id = UUID()
- @Published public var history: [ChatMessage] = []
- @Published public var isReceivingMessage = false
- public var temperature: Double? {
- get {
- configuration.overriding.temperature
- }
- set {
- configuration.overriding.temperature = newValue
- objectWillChange.send()
- }
- }
- public var chatModelId: String? {
- get {
- configuration.overriding.modelId
- }
- set {
- configuration.overriding.modelId = newValue
- objectWillChange.send()
- }
- }
- private let configuration: OverridingChatGPTConfiguration
- public var pluginIdentifiers: [String] = []
- public var systemPrompt = ""
-
- public var title: String {
- let defaultTitle = "Chat"
- guard let lastMessageText = history
- .filter({ $0.role == .assistant || $0.role == .user })
- .last?
- .text else { return defaultTitle }
- if lastMessageText.isEmpty { return defaultTitle }
- let trimmed = lastMessageText
- .trimmingCharacters(in: .punctuationCharacters)
- .trimmingCharacters(in: .whitespacesAndNewlines)
- if trimmed.starts(with: "```") {
- return "Code Block"
- } else {
- return trimmed
- }
- }
-
- public var extraSystemPrompt = ""
- public var onMessageSend: (String) -> Void
- public var onStop: () -> Void
- public var onClear: () -> Void
- public var onDeleteMessage: (MessageID) -> Void
- public var onResendMessage: (MessageID) -> Void
- public var onResetPrompt: () -> Void
- public var onRunCustomCommand: (CustomCommand) -> Void = { _ in }
- public var onSetAsExtraPrompt: (MessageID) -> Void
-
- public init(
- configuration: OverridingChatGPTConfiguration,
- history: [ChatMessage] = [],
- isReceivingMessage: Bool = false,
- pluginIdentifiers: [String] = [],
- onMessageSend: @escaping (String) -> Void = { _ in },
- onStop: @escaping () -> Void = {},
- onClear: @escaping () -> Void = {},
- onDeleteMessage: @escaping (MessageID) -> Void = { _ in },
- onResendMessage: @escaping (MessageID) -> Void = { _ in },
- onResetPrompt: @escaping () -> Void = {},
- onRunCustomCommand: @escaping (CustomCommand) -> Void = { _ in },
- onSetAsExtraPrompt: @escaping (MessageID) -> Void = { _ in }
- ) {
- self.configuration = configuration
- self.history = history
- self.isReceivingMessage = isReceivingMessage
- self.pluginIdentifiers = pluginIdentifiers
- self.onMessageSend = onMessageSend
- self.onStop = onStop
- self.onClear = onClear
- self.onDeleteMessage = onDeleteMessage
- self.onResendMessage = onResendMessage
- self.onResetPrompt = onResetPrompt
- self.onRunCustomCommand = onRunCustomCommand
- self.onSetAsExtraPrompt = onSetAsExtraPrompt
- }
-
- public func send(_ message: String) { onMessageSend(message) }
- public func stop() { onStop() }
- public func clear() { onClear() }
- public func deleteMessage(id: MessageID) { onDeleteMessage(id) }
- public func resendMessage(id: MessageID) { onResendMessage(id) }
- public func resetPrompt() { onResetPrompt() }
- public func triggerCustomCommand(_ command: CustomCommand) {
- onRunCustomCommand(command)
- }
-
- public func setAsExtraPrompt(id: MessageID) { onSetAsExtraPrompt(id) }
-}
-
-public struct ChatMessage: Equatable {
- public enum Role {
- case user
- case assistant
- case function
- case ignored
- }
-
- public var id: String
- public var role: Role
- public var text: String
-
- public init(id: String, role: Role, text: String) {
- self.id = id
- self.role = role
- self.text = text
- }
-}
-
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 330cc92f..6c117c9a 100644
--- a/Core/Sources/ChatGPTChatTab/Styles.swift
+++ b/Core/Sources/ChatGPTChatTab/Styles.swift
@@ -9,7 +9,7 @@ extension Color {
if appearance.isDarkMode {
return #colorLiteral(red: 0.1580096483, green: 0.1730263829, blue: 0.2026666105, alpha: 1)
}
- return .white
+ return #colorLiteral(red: 0.9896564803, green: 0.9896564803, blue: 0.9896564803, alpha: 1)
}))
}
@@ -18,7 +18,7 @@ extension Color {
if appearance.isDarkMode {
return #colorLiteral(red: 0.2284317913, green: 0.2145925438, blue: 0.3214019983, alpha: 1)
}
- return #colorLiteral(red: 0.896820749, green: 0.8709097223, blue: 0.9766687925, alpha: 1)
+ return #colorLiteral(red: 0.9458052187, green: 0.9311983998, blue: 0.9906365955, alpha: 1)
}))
}
}
@@ -33,67 +33,134 @@ extension NSAppearance {
}
}
-extension MarkdownUI.Theme {
- static func custom(fontSize: Double) -> MarkdownUI.Theme {
- .gitHub.text {
- ForegroundColor(.primary)
- BackgroundColor(Color.clear)
- FontSize(fontSize)
- }
- .codeBlock { configuration in
- configuration.label
- .relativeLineSpacing(.em(0.225))
- .markdownTextStyle {
- FontFamilyVariant(.monospaced)
- FontSize(.em(0.85))
- }
- .padding(16)
- .padding(.top, 14)
- .background(Color(nsColor: .textBackgroundColor).opacity(0.7))
- .clipShape(RoundedRectangle(cornerRadius: 6))
- .overlay(alignment: .top) {
- HStack(alignment: .center) {
- Text(configuration.language ?? "code")
- .foregroundStyle(.tertiary)
- .font(.callout)
- .padding(.leading, 8)
- .lineLimit(1)
- Spacer()
- CopyButton {
- NSPasteboard.general.clearContents()
- NSPasteboard.general.setString(configuration.content, forType: .string)
- }
+extension View {
+ var messageBubbleCornerRadius: Double { 8 }
+
+ func codeBlockLabelStyle() -> some View {
+ relativeLineSpacing(.em(0.225))
+ .markdownTextStyle {
+ FontFamilyVariant(.monospaced)
+ FontSize(.em(0.85))
+ }
+ .padding(16)
+ .padding(.top, 14)
+ }
+
+ func codeBlockStyle(
+ _ configuration: CodeBlockConfiguration,
+ backgroundColor: Color,
+ labelColor: Color
+ ) -> some View {
+ background(backgroundColor)
+ .clipShape(RoundedRectangle(cornerRadius: 6))
+ .overlay(alignment: .top) {
+ HStack(alignment: .center) {
+ Text(configuration.language ?? "code")
+ .foregroundStyle(labelColor)
+ .font(.callout.bold())
+ .padding(.leading, 8)
+ .lineLimit(1)
+ Spacer()
+ CopyButton {
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(configuration.content, forType: .string)
}
}
- .markdownMargin(top: 4, bottom: 16)
- }
+ }
+ .overlay {
+ RoundedRectangle(cornerRadius: 6).stroke(Color.primary.opacity(0.05), lineWidth: 1)
+ }
+ .markdownMargin(top: 4, bottom: 16)
}
+}
- 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
+ }
+}
+
+struct VerticalScrollingFixViewRepresentable: NSViewRepresentable where Content: View {
+ let content: Content
+
+ func makeNSView(context: Context) -> NSHostingView {
+ return VerticalScrollingFixHostingView(rootView: content)
+ }
+
+ func updateNSView(_ nsView: NSHostingView, context: Context) {}
+}
+
+struct VerticalScrollingFixWrapper: View where Content: View {
+ let content: () -> Content
+
+ init(@ViewBuilder content: @escaping () -> Content) {
+ self.content = content
+ }
+
+ var body: some View {
+ VerticalScrollingFixViewRepresentable(content: self.content())
+ }
+}
+
+extension View {
+ /// https://stackoverflow.com/questions/64920744/swiftui-nested-scrollviews-problem-on-macos
+ @ViewBuilder func workaroundForVerticalScrollingBugInMacOS() -> some View {
+ VerticalScrollingFixWrapper { self }
+ }
+}
+
+struct RoundedCorners: Shape {
+ var tl: CGFloat = 0.0
+ var tr: CGFloat = 0.0
+ var bl: CGFloat = 0.0
+ var br: CGFloat = 0.0
+
+ func path(in rect: CGRect) -> Path {
+ Path { path in
+
+ let w = rect.size.width
+ let h = rect.size.height
+
+ // Make sure we do not exceed the size of the rectangle
+ let tr = min(min(self.tr, h / 2), w / 2)
+ let tl = min(min(self.tl, h / 2), w / 2)
+ let bl = min(min(self.bl, h / 2), w / 2)
+ let br = min(min(self.br, h / 2), w / 2)
+
+ path.move(to: CGPoint(x: w / 2.0, y: 0))
+ path.addLine(to: CGPoint(x: w - tr, y: 0))
+ path.addArc(
+ center: CGPoint(x: w - tr, y: tr),
+ radius: tr,
+ startAngle: Angle(degrees: -90),
+ endAngle: Angle(degrees: 0),
+ clockwise: false
+ )
+ path.addLine(to: CGPoint(x: w, y: h - br))
+ path.addArc(
+ center: CGPoint(x: w - br, y: h - br),
+ radius: br,
+ startAngle: Angle(degrees: 0),
+ endAngle: Angle(degrees: 90),
+ clockwise: false
+ )
+ path.addLine(to: CGPoint(x: bl, y: h))
+ path.addArc(
+ center: CGPoint(x: bl, y: h - bl),
+ radius: bl,
+ startAngle: Angle(degrees: 90),
+ endAngle: Angle(degrees: 180),
+ clockwise: false
+ )
+ path.addLine(to: CGPoint(x: 0, y: tl))
+ path.addArc(
+ center: CGPoint(x: tl, y: tl),
+ radius: tl,
+ startAngle: Angle(degrees: 180),
+ endAngle: Angle(degrees: 270),
+ clockwise: false
+ )
+ path.closeSubpath()
}
}
}
diff --git a/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift b/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift
new file mode 100644
index 00000000..bcd9a455
--- /dev/null
+++ b/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift
@@ -0,0 +1,295 @@
+import ComposableArchitecture
+import Foundation
+import MarkdownUI
+import SharedUIComponents
+import SwiftUI
+
+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
+
+ @State var isReferencesPresented = false
+ @State var isReferencesHovered = false
+
+ var body: some View {
+ HStack(alignment: .bottom, spacing: 2) {
+ VStack(alignment: .leading, spacing: 16) {
+ if !references.isEmpty {
+ Button(action: {
+ isReferencesPresented.toggle()
+ }, label: {
+ HStack(spacing: 4) {
+ Image(systemName: "plus.circle")
+ Text("Used \(references.count) references")
+ }
+ .padding(8)
+ .background {
+ RoundedRectangle(cornerRadius: r - 4)
+ .foregroundStyle(Color(isReferencesHovered ? .black : .clear))
+ }
+ .overlay {
+ RoundedRectangle(cornerRadius: r - 4)
+ .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
+ }
+ .foregroundStyle(.secondary)
+ })
+ .buttonStyle(.plain)
+ .popover(isPresented: $isReferencesPresented, arrowEdge: .trailing) {
+ ReferenceList(references: references, chat: chat)
+ }
+ }
+
+ ThemedMarkdownText(markdownContent)
+ }
+ .frame(alignment: .trailing)
+ .padding()
+ .background {
+ RoundedCorners(tl: r, tr: r, bl: 0, br: r)
+ .fill(Color.contentBackground)
+ }
+ .overlay {
+ RoundedCorners(tl: r, tr: r, bl: 0, br: r)
+ .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
+ }
+ .padding(.leading, 8)
+ .shadow(color: .black.opacity(0.05), radius: 6)
+ .contextMenu {
+ Button("Copy") {
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(text, forType: .string)
+ }
+
+ Button("Set as Extra System Prompt") {
+ chat.send(.setAsExtraPromptButtonTapped(id))
+ }
+
+ Divider()
+
+ Button("Delete") {
+ chat.send(.deleteMessageButtonTapped(id))
+ }
+ }
+
+ CopyButton {
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(text, forType: .string)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.trailing, 2)
+ }
+}
+
+struct ReferenceList: View {
+ let references: [DisplayedChatMessage.Reference]
+ let chat: StoreOf
+
+ var body: some View {
+ WithPerceptionTracking {
+ 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/FunctionMessage.swift b/Core/Sources/ChatGPTChatTab/Views/FunctionMessage.swift
new file mode 100644
index 00000000..3e6b031a
--- /dev/null
+++ b/Core/Sources/ChatGPTChatTab/Views/FunctionMessage.swift
@@ -0,0 +1,30 @@
+import Foundation
+import MarkdownUI
+import SwiftUI
+
+struct FunctionMessage: View {
+ let id: String
+ let text: String
+ @AppStorage(\.chatFontSize) var chatFontSize
+
+ var body: some View {
+ Markdown(text)
+ .textSelection(.enabled)
+ .markdownTheme(.functionCall(fontSize: chatFontSize))
+ .padding(.vertical, 2)
+ .padding(.trailing, 2)
+ }
+}
+
+#Preview {
+ FunctionMessage(id: "1", text: """
+ Searching for something...
+ - abc
+ - [def](https://1.com)
+ > hello
+ > hi
+ """)
+ .padding()
+ .fixedSize()
+}
+
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
new file mode 100644
index 00000000..dba6bfbf
--- /dev/null
+++ b/Core/Sources/ChatGPTChatTab/Views/Instructions.swift
@@ -0,0 +1,83 @@
+import ComposableArchitecture
+import Foundation
+import MarkdownUI
+import SwiftUI
+
+struct Instruction: View {
+ let chat: StoreOf
+
+ var body: some View {
+ WithPerceptionTracking {
+ Group {
+ Markdown(
+ """
+ You can use plugins to perform various tasks.
+
+ | Plugin Name | Description |
+ | --- | --- |
+ | `/shell` | Runs a command under the project root |
+ | `/shortcut(name)` | Runs a shortcut from the Shortcuts.app, with the previous message as input |
+
+ To use plugins, you can prefix a message with `/pluginName`.
+ """
+ )
+ .modifier(InstructionModifier())
+
+ Markdown(
+ """
+ You can use scopes to give the bot extra abilities.
+
+ | Scope Name | Abilities |
+ | --- | --- |
+ | `@file` | Read the metadata of the editing file |
+ | `@code` | Read the code and metadata in the editing file |
+ | `@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())
+
+ let scopes = chat.chatMenu.defaultScopes
+ Markdown(
+ """
+ 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())
+ }
+ }
+ }
+
+ struct InstructionModifier: ViewModifier {
+ @AppStorage(\.chatFontSize) var chatFontSize
+
+ func body(content: Content) -> some View {
+ content
+ .textSelection(.enabled)
+ .markdownTheme(.instruction(fontSize: chatFontSize))
+ .opacity(0.8)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding()
+ .overlay {
+ RoundedRectangle(cornerRadius: 8)
+ .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
+ }
+ }
+ }
+}
+
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
new file mode 100644
index 00000000..edac231a
--- /dev/null
+++ b/Core/Sources/ChatGPTChatTab/Views/UserMessage.swift
@@ -0,0 +1,80 @@
+import ComposableArchitecture
+import Foundation
+import MarkdownUI
+import SwiftUI
+
+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(markdownContent)
+ .frame(alignment: .leading)
+ .padding()
+ .background {
+ RoundedCorners(tl: r, tr: r, bl: r, br: 0)
+ .fill(Color.userChatContentBackground)
+ }
+ .overlay {
+ RoundedCorners(tl: r, tr: r, bl: r, br: 0)
+ .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
+ }
+ .padding(.leading)
+ .padding(.trailing, 8)
+ .shadow(color: .black.opacity(0.05), radius: 6)
+ .frame(maxWidth: .infinity, alignment: .trailing)
+ .contextMenu {
+ Button("Copy") {
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(text, forType: .string)
+ }
+
+ Button("Send Again") {
+ chat.send(.resendMessageButtonTapped(id))
+ }
+
+ Button("Set as Extra System Prompt") {
+ chat.send(.setAsExtraPromptButtonTapped(id))
+ }
+
+ Divider()
+
+ Button("Delete") {
+ chat.send(.deleteMessageButtonTapped(id))
+ }
+ }
+ }
+}
+
+#Preview {
+ let text = #"""
+ Please buy me a coffee!
+ | Coffee | Milk |
+ |--------|------|
+ | Espresso | No |
+ | Latte | Yes |
+ ```swift
+ func foo() {}
+ ```
+ ```objectivec
+ - (void)bar {}
+ ```
+ """#
+
+ return UserMessage(
+ id: "A",
+ text: text,
+ markdownContent: .init(text),
+ chat: .init(
+ initialState: .init(history: [] as [DisplayedChatMessage], isReceivingMessage: false),
+ reducer: { Chat(service: .init()) }
+ )
+ )
+ .padding()
+ .fixedSize(horizontal: true, vertical: true)
+}
+
diff --git a/Core/Sources/ChatPlugin/AITerminalChatPlugin.swift b/Core/Sources/ChatPlugin/AITerminalChatPlugin.swift
deleted file mode 100644
index 803f2806..00000000
--- a/Core/Sources/ChatPlugin/AITerminalChatPlugin.swift
+++ /dev/null
@@ -1,197 +0,0 @@
-import Environment
-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 e4c60aca..00000000
--- a/Core/Sources/ChatPlugins/SearchChatPlugin/SearchChatPlugin.swift
+++ /dev/null
@@ -1,99 +0,0 @@
-import ChatPlugin
-import Environment
-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 f882b750..00000000
--- a/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutChatPlugin.swift
+++ /dev/null
@@ -1,125 +0,0 @@
-import ChatPlugin
-import Environment
-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.messages.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],
- currentDirectoryPath: "/",
- 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 76f16677..00000000
--- a/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutInputChatPlugin.swift
+++ /dev/null
@@ -1,127 +0,0 @@
-import ChatPlugin
-import Environment
-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.messages.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],
- currentDirectoryPath: "/",
- 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/AllContextCollector.swift b/Core/Sources/ChatService/AllContextCollector.swift
index ec365e9d..c13aa612 100644
--- a/Core/Sources/ChatService/AllContextCollector.swift
+++ b/Core/Sources/ChatService/AllContextCollector.swift
@@ -6,7 +6,6 @@ import WebChatContextCollector
import ProChatContextCollectors
let allContextCollectors: [any ChatContextCollector] = [
SystemInfoChatContextCollector(),
- ActiveDocumentChatContextCollector(),
WebChatContextCollector(),
ProChatContextCollectors(),
]
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 57b6bbec..e1b0eb54 100644
--- a/Core/Sources/ChatService/ChatService.swift
+++ b/Core/Sources/ChatService/ChatService.swift
@@ -1,24 +1,29 @@
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
@Published public internal(set) var systemPrompt = UserDefaults.shared
.value(for: \.defaultChatSystemPrompt)
@Published public internal(set) var extraSystemPrompt = ""
+ @Published public var defaultScopes = Set()
let pluginController: ChatPluginController
var cancellable = Set()
- init(
+ init(
memory: ContextAwareAutoManagedChatGPTMemory,
configuration: OverridingChatGPTConfiguration,
chatGPTService: T
@@ -36,49 +41,75 @@ public final class ChatService: ObservableObject {
public convenience init() {
let configuration = UserPreferenceChatGPTConfiguration().overriding()
+ /// Used by context collector
+ let extraConfiguration = configuration.overriding()
+ extraConfiguration.textWindowTerminator = {
+ guard let last = $0.last else { return false }
+ return last.isNewline || last.isPunctuation
+ }
let memory = ContextAwareAutoManagedChatGPTMemory(
- configuration: configuration,
+ configuration: extraConfiguration,
functionProvider: ChatFunctionProvider()
)
self.init(
memory: memory,
configuration: configuration,
- chatGPTService: ChatGPTService(
+ chatGPTService: LegacyChatGPTService(
memory: memory,
- configuration: configuration,
+ configuration: extraConfiguration,
functionProvider: memory.functionProvider
)
)
-
+
resetDefaultScopes()
memory.chatService = self
memory.observeHistoryChange { [weak self] in
- self?.objectWillChange.send()
+ Task { [weak self] in
+ self?.chatHistory = await memory.history
+ }
}
}
-
+
public func resetDefaultScopes() {
- if UserDefaults.shared.value(for: \.useCodeScopeByDefaultInChatContext) {
- memory.contextController.defaultScopes = ["code"]
- } else {
- memory.contextController.defaultScopes = ["file"]
+ var scopes = Set()
+ if UserDefaults.shared.value(for: \.enableFileScopeByDefaultInChatContext) {
+ scopes.insert(.file)
+ }
+
+ if UserDefaults.shared.value(for: \.enableCodeScopeByDefaultInChatContext) {
+ scopes.insert(.code)
+ }
+
+ if UserDefaults.shared.value(for: \.enableProjectScopeByDefaultInChatContext) {
+ scopes.insert(.project)
+ }
+
+ if UserDefaults.shared.value(for: \.enableSenseScopeByDefaultInChatContext) {
+ scopes.insert(.sense)
}
+
+ if UserDefaults.shared.value(for: \.enableWebScopeByDefaultInChatContext) {
+ scopes.insert(.web)
+ }
+
+ defaultScopes = scopes
}
public func send(content: String) async throws {
+ memory.contextController.defaultScopes = defaultScopes
guard !isReceivingMessage else { throw CancellationError() }
let handledInPlugin = try await pluginController.handleContent(content)
if handledInPlugin { return }
-
- let stream = try await chatGPTService.send(content: content, summary: nil)
isReceivingMessage = true
+ defer { isReceivingMessage = false }
+
+ let stream = try await chatGPTService.send(content: content, summary: nil)
do {
- for try await _ in stream {}
- isReceivingMessage = false
- } catch {
- isReceivingMessage = false
- }
+ for try await _ in stream {
+ try Task.checkCancellation()
+ }
+ } catch {}
}
public func sendAndWait(content: String) async throws -> String {
@@ -94,9 +125,9 @@ public final class ChatService: ObservableObject {
await chatGPTService.stopReceivingMessage()
isReceivingMessage = false
- // if it's stopped before the function finishes, remove the function call.
+ // if it's stopped before the tool calls finish, remove the message.
await memory.mutateHistory { history in
- if history.last?.role == .assistant, history.last?.functionCall != nil {
+ if history.last?.role == .assistant, history.last?.toolCalls != nil {
history.removeLast()
}
}
@@ -112,7 +143,6 @@ public final class ChatService: ObservableObject {
public func resetPrompt() async {
systemPrompt = UserDefaults.shared.value(for: \.defaultChatSystemPrompt)
extraSystemPrompt = ""
- resetDefaultScopes()
}
public func deleteMessage(id: String) async {
@@ -188,8 +218,14 @@ public final class ChatService: ObservableObject {
guard let info else { return }
let templateProcessor = CustomCommandTemplateProcessor()
- mutateSystemPrompt(info.specifiedSystemPrompt.map(templateProcessor.process))
- mutateExtraSystemPrompt(info.extraSystemPrompt.map(templateProcessor.process) ?? "")
+ if let specifiedSystemPrompt = info.specifiedSystemPrompt {
+ await mutateSystemPrompt(templateProcessor.process(specifiedSystemPrompt))
+ }
+ if let extraSystemPrompt = info.extraSystemPrompt {
+ await mutateExtraSystemPrompt(templateProcessor.process(extraSystemPrompt))
+ } else {
+ mutateExtraSystemPrompt("")
+ }
let customCommandPrefix = {
if let name = info.name { return "[\(name)] " }
@@ -221,9 +257,9 @@ public final class ChatService: ObservableObject {
let templateProcessor = CustomCommandTemplateProcessor()
if let systemPrompt {
if overwriteSystemPrompt {
- mutateSystemPrompt(templateProcessor.process(systemPrompt))
+ await mutateSystemPrompt(templateProcessor.process(systemPrompt))
} else {
- mutateExtraSystemPrompt(templateProcessor.process(systemPrompt))
+ await mutateExtraSystemPrompt(templateProcessor.process(systemPrompt))
}
}
return try await sendAndWait(content: templateProcessor.process(prompt))
@@ -236,10 +272,10 @@ public final class ChatService: ObservableObject {
) async throws -> String {
let templateProcessor = CustomCommandTemplateProcessor()
if let systemPrompt {
- mutateSystemPrompt(templateProcessor.process(systemPrompt))
+ await mutateSystemPrompt(templateProcessor.process(systemPrompt))
}
if let extraSystemPrompt {
- mutateExtraSystemPrompt(templateProcessor.process(extraSystemPrompt))
+ await mutateExtraSystemPrompt(templateProcessor.process(extraSystemPrompt))
}
return try await sendAndWait(content: templateProcessor.process(prompt))
}
diff --git a/Core/Sources/ChatService/ContextAwareAutoManagedChatGPTMemory.swift b/Core/Sources/ChatService/ContextAwareAutoManagedChatGPTMemory.swift
index 0a22051c..32d65694 100644
--- a/Core/Sources/ChatService/ContextAwareAutoManagedChatGPTMemory.swift
+++ b/Core/Sources/ChatService/ContextAwareAutoManagedChatGPTMemory.swift
@@ -7,30 +7,23 @@ public final class ContextAwareAutoManagedChatGPTMemory: ChatGPTMemory {
let functionProvider: ChatFunctionProvider
weak var chatService: ChatService?
- public var messages: [ChatMessage] {
- get async { await memory.messages }
- }
-
- public var remainingTokens: Int? {
- get async { await memory.remainingTokens }
- }
-
public var history: [ChatMessage] {
get async { await memory.history }
}
-
+
func observeHistoryChange(_ observer: @escaping () -> Void) {
memory.observeHistoryChange(observer)
}
init(
- configuration: ChatGPTConfiguration,
+ configuration: OverridingChatGPTConfiguration,
functionProvider: ChatFunctionProvider
) {
memory = AutoManagedChatGPTMemory(
systemPrompt: "",
configuration: configuration,
- functionProvider: functionProvider
+ functionProvider: functionProvider,
+ maxNumberOfMessages: UserDefaults.shared.value(for: \.chatGPTMaxMessageCount)
)
contextController = DynamicContextController(
memory: memory,
@@ -45,14 +38,17 @@ public final class ContextAwareAutoManagedChatGPTMemory: ChatGPTMemory {
await memory.mutateHistory(update)
}
- public func refresh() async {
+ public func generatePrompt() async -> ChatGPTPrompt {
let content = (await memory.history)
- .last(where: { $0.role == .user || $0.role == .function })?.content
- try? await contextController.updatePromptToMatchContent(systemPrompt: """
- \(chatService?.systemPrompt ?? "")
- \(chatService?.extraSystemPrompt ?? "")
- """, content: content ?? "")
- await memory.refresh()
+ .last(where: { $0.role == .user })?.content
+ try? await contextController.collectContextInformation(
+ systemPrompt: """
+ \(chatService?.systemPrompt ?? "")
+ \(chatService?.extraSystemPrompt ?? "")
+ """.trimmingCharacters(in: .whitespacesAndNewlines),
+ content: content ?? ""
+ )
+ return await memory.generatePrompt()
}
}
diff --git a/Core/Sources/ChatService/DynamicContextController.swift b/Core/Sources/ChatService/DynamicContextController.swift
index 5c1cc81a..11ae9753 100644
--- a/Core/Sources/ChatService/DynamicContextController.swift
+++ b/Core/Sources/ChatService/DynamicContextController.swift
@@ -1,7 +1,6 @@
import ChatContextCollector
import Foundation
import OpenAIService
-import Parsing
import Preferences
import XcodeInspector
@@ -9,13 +8,13 @@ final class DynamicContextController {
let contextCollectors: [ChatContextCollector]
let memory: AutoManagedChatGPTMemory
let functionProvider: ChatFunctionProvider
- let configuration: ChatGPTConfiguration
- var defaultScopes = [] as Set
+ let configuration: OverridingChatGPTConfiguration
+ var defaultScopes = [] as Set
convenience init(
memory: AutoManagedChatGPTMemory,
functionProvider: ChatFunctionProvider,
- configuration: ChatGPTConfiguration,
+ configuration: OverridingChatGPTConfiguration,
contextCollectors: ChatContextCollector...
) {
self.init(
@@ -29,7 +28,7 @@ final class DynamicContextController {
init(
memory: AutoManagedChatGPTMemory,
functionProvider: ChatFunctionProvider,
- configuration: ChatGPTConfiguration,
+ configuration: OverridingChatGPTConfiguration,
contextCollectors: [ChatContextCollector]
) {
self.memory = memory
@@ -38,59 +37,87 @@ final class DynamicContextController {
self.contextCollectors = contextCollectors
}
- func updatePromptToMatchContent(systemPrompt: String, content: String) async throws {
+ func collectContextInformation(systemPrompt: String, content: String) async throws {
var content = content
var scopes = Self.parseScopes(&content)
scopes.formUnion(defaultScopes)
+ let overridingChatModelId = {
+ var ids = [String]()
+ if scopes.contains(.sense) {
+ ids.append(UserDefaults.shared.value(for: \.preferredChatModelIdForSenseScope))
+ }
+
+ if scopes.contains(.project) {
+ ids.append(UserDefaults.shared.value(for: \.preferredChatModelIdForProjectScope))
+ }
+
+ if scopes.contains(.web) {
+ ids.append(UserDefaults.shared.value(for: \.preferredChatModelIdForWebScope))
+ }
+
+ let chatModels = UserDefaults.shared.value(for: \.chatModels)
+ let idIndexMap = chatModels.enumerated().reduce(into: [String: Int]()) {
+ $0[$1.element.id] = $1.offset
+ }
+ return ids.filter { !$0.isEmpty }.sorted(by: {
+ let lhs = idIndexMap[$0] ?? Int.max
+ let rhs = idIndexMap[$1] ?? Int.max
+ return lhs < rhs
+ }).first
+ }()
+
+ configuration.overriding.modelId = overridingChatModelId
+
functionProvider.removeAll()
let language = UserDefaults.shared.value(for: \.chatGPTLanguage)
let oldMessages = await memory.history
- let contexts = contextCollectors.compactMap {
- $0.generateContext(
- history: oldMessages,
- scopes: scopes,
- content: content,
- configuration: configuration
- )
+ let contexts = await withTaskGroup(
+ of: ChatContext.self
+ ) { [scopes, content, configuration] group in
+ for collector in contextCollectors {
+ group.addTask {
+ await collector.generateContext(
+ history: oldMessages,
+ scopes: scopes,
+ content: content,
+ configuration: configuration
+ )
+ }
+ }
+ var contexts = [ChatContext]()
+ for await context in group {
+ contexts.append(context)
+ }
+ return contexts
}
+
+ let contextSystemPrompt = contexts
+ .map(\.systemPrompt)
+ .filter { !$0.isEmpty }
+ .joined(separator: "\n\n")
+
+ let retrievedContent = contexts
+ .flatMap(\.retrievedContent)
+ .filter { !$0.document.content.isEmpty }
+ .sorted { $0.priority > $1.priority }
+ .prefix(15)
+
let contextualSystemPrompt = """
\(language.isEmpty ? "" : "You must always reply in \(language)")
\(systemPrompt)
-
- \(contexts.map(\.systemPrompt).filter { !$0.isEmpty }.joined(separator: "\n\n"))
- """
+ """.trimmingCharacters(in: .whitespacesAndNewlines)
await memory.mutateSystemPrompt(contextualSystemPrompt)
+ await memory.mutateContextSystemPrompt(contextSystemPrompt)
+ await memory.mutateRetrievedContent(retrievedContent.map(\.document))
functionProvider.append(functions: contexts.flatMap(\.functions))
}
}
extension DynamicContextController {
- static func parseScopes(_ prompt: inout String) -> Set {
- guard !prompt.isEmpty else { return [] }
- do {
- let parser = Parse {
- "@"
- Many {
- Prefix { $0.isLetter }
- } separator: {
- "+"
- } terminator: {
- " "
- }
- Skip {
- Many {
- " "
- }
- }
- Rest()
- }
- let (scopes, rest) = try parser.parse(prompt)
- prompt = String(rest)
- return Set(scopes.map(String.init))
- } catch {
- return []
- }
+ static func parseScopes(_ prompt: inout String) -> Set {
+ let parser = MessageScopeParser()
+ return parser(&prompt)
}
}
diff --git a/Core/Sources/Client/AsyncXPCService.swift b/Core/Sources/Client/AsyncXPCService.swift
deleted file mode 100644
index bd366d0f..00000000
--- a/Core/Sources/Client/AsyncXPCService.swift
+++ /dev/null
@@ -1,224 +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 performAction(name: String, arguments: String) async throws -> String {
- try await withXPCServiceConnected(connection: connection) {
- service, continuation in
- service.performAction(name: name, arguments: arguments) {
- continuation.resume($0)
- }
- }
- }
-}
-
-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/CodeiumService/CodeiumInstallationManager.swift b/Core/Sources/CodeiumService/CodeiumInstallationManager.swift
deleted file mode 100644
index 27d15ad0..00000000
--- a/Core/Sources/CodeiumService/CodeiumInstallationManager.swift
+++ /dev/null
@@ -1,132 +0,0 @@
-import Foundation
-import Terminal
-
-public struct CodeiumInstallationManager {
- private static var isInstalling = false
- static let latestSupportedVersion = "1.2.93"
-
- public init() {}
-
- public enum InstallationStatus {
- case notInstalled
- case installed(String)
- case outdated(current: String, latest: String)
- case unsupported(current: String, latest: String)
- }
-
- public func checkInstallation() -> InstallationStatus {
- guard let urls = try? CodeiumSuggestionService.createFoldersIfNeeded()
- else { return .notInstalled }
- let executableFolderURL = urls.executableURL
- let binaryURL = executableFolderURL.appendingPathComponent("language_server")
- let versionFileURL = executableFolderURL.appendingPathComponent("version")
-
- if !FileManager.default.fileExists(atPath: binaryURL.path) {
- return .notInstalled
- }
-
- if FileManager.default.fileExists(atPath: versionFileURL.path),
- let versionData = try? Data(contentsOf: versionFileURL),
- let version = String(data: versionData, encoding: .utf8)
- {
- switch version.compare(Self.latestSupportedVersion) {
- case .orderedAscending:
- return .outdated(current: version, latest: Self.latestSupportedVersion)
- case .orderedSame:
- return .installed(version)
- case .orderedDescending:
- return .unsupported(current: version, latest: Self.latestSupportedVersion)
- }
- }
-
- return .outdated(current: "Unknown", latest: Self.latestSupportedVersion)
- }
-
- public enum InstallationStep {
- case downloading
- case uninstalling
- case decompressing
- case done
- }
-
- public func installLatestVersion() -> AsyncThrowingStream {
- AsyncThrowingStream { continuation in
- Task {
- guard !CodeiumInstallationManager.isInstalling else {
- continuation.finish(throwing: CodeiumError.languageServiceIsInstalling)
- return
- }
- CodeiumInstallationManager.isInstalling = true
- defer { CodeiumInstallationManager.isInstalling = false }
- do {
- continuation.yield(.downloading)
- let urls = try CodeiumSuggestionService.createFoldersIfNeeded()
- let urlString =
- "https://github.com/Exafunction/codeium/releases/download/language-server-v\(Self.latestSupportedVersion)/language_server_macos_\(isAppleSilicon() ? "arm" : "x64").gz"
- guard let url = URL(string: urlString) else { return }
-
- // download
- let (fileURL, _) = try await URLSession.shared.download(from: url)
- let targetURL = urls.executableURL.appendingPathComponent("language_server")
- .appendingPathExtension("gz")
- try FileManager.default.copyItem(at: fileURL, to: targetURL)
- defer { try? FileManager.default.removeItem(at: targetURL) }
-
- // uninstall
- continuation.yield(.uninstalling)
- try await uninstall()
-
- // extract file
- continuation.yield(.decompressing)
- let terminal = Terminal()
- _ = try await terminal.runCommand(
- "/usr/bin/gunzip",
- arguments: [targetURL.path],
- environment: [:]
- )
-
- // update permission 755
- try FileManager.default.setAttributes(
- [.posixPermissions: 0o755],
- ofItemAtPath: targetURL.deletingPathExtension().path
- )
-
- // create version file
- let data = Self.latestSupportedVersion.data(using: .utf8)
- FileManager.default.createFile(
- atPath: urls.executableURL.appendingPathComponent("version").path,
- contents: data
- )
-
- continuation.yield(.done)
- continuation.finish()
- } catch {
- continuation.finish(throwing: error)
- }
- }
- }
- }
-
- public func uninstall() async throws {
- guard let urls = try? CodeiumSuggestionService.createFoldersIfNeeded()
- else { return }
- let executableFolderURL = urls.executableURL
- let binaryURL = executableFolderURL.appendingPathComponent("language_server")
- let versionFileURL = executableFolderURL.appendingPathComponent("version")
- if FileManager.default.fileExists(atPath: binaryURL.path) {
- try FileManager.default.removeItem(at: binaryURL)
- }
- if FileManager.default.fileExists(atPath: versionFileURL.path) {
- try FileManager.default.removeItem(at: versionFileURL)
- }
- }
-}
-
-func isAppleSilicon() -> Bool {
- var result = false
- #if arch(arm64)
- result = true
- #endif
- return result
-}
-
diff --git a/Core/Sources/GitHubCopilotService/GitHubCopilotRequest.swift b/Core/Sources/GitHubCopilotService/GitHubCopilotRequest.swift
deleted file mode 100644
index f7ccefb1..00000000
--- a/Core/Sources/GitHubCopilotService/GitHubCopilotRequest.swift
+++ /dev/null
@@ -1,215 +0,0 @@
-import Foundation
-import JSONRPC
-import LanguageServerProtocol
-import SuggestionModel
-
-struct GitHubCopilotDoc: Codable {
- var source: String
- var tabSize: Int
- var indentSize: Int
- var insertSpaces: Bool
- var path: String
- var uri: String
- var relativePath: String
- var languageId: CodeLanguage
- var position: Position
- /// Buffer version. Not sure what this is for, not sure how to get it
- var version: Int = 0
-}
-
-protocol GitHubCopilotRequestType {
- associatedtype Response: Codable
- var request: ClientRequest { get }
-}
-
-enum GitHubCopilotRequest {
- struct SetEditorInfo: GitHubCopilotRequestType {
- struct Response: Codable {}
-
- var networkProxy: JSONValue? {
- let host = UserDefaults.shared.value(for: \.gitHubCopilotProxyHost)
- if host.isEmpty { return nil }
- var port = UserDefaults.shared.value(for: \.gitHubCopilotProxyPort)
- if port.isEmpty { port = "80" }
- let username = UserDefaults.shared.value(for: \.gitHubCopilotProxyUsername)
- if username.isEmpty {
- return .hash([
- "host": .string(host),
- "port": .number(Double(Int(port) ?? 80)),
- "rejectUnauthorized": .bool(UserDefaults.shared
- .value(for: \.gitHubCopilotUseStrictSSL)),
- ])
- } else {
- return .hash([
- "host": .string(host),
- "port": .number(Double(Int(port) ?? 80)),
- "rejectUnauthorized": .bool(UserDefaults.shared
- .value(for: \.gitHubCopilotUseStrictSSL)),
- "username": .string(username),
- "password": .string(UserDefaults.shared
- .value(for: \.gitHubCopilotProxyPassword)),
-
- ])
- }
- }
-
- var request: ClientRequest {
- if let networkProxy {
- return .custom("setEditorInfo", .hash([
- "editorInfo": .hash([
- "name": "Xcode",
- "version": "",
- ]),
- "editorPluginInfo": .hash([
- "name": "Copilot for Xcode",
- "version": "",
- ]),
- "networkProxy": networkProxy,
- ]))
- }
-
- return .custom("setEditorInfo", .hash([
- "editorInfo": .hash([
- "name": "Xcode",
- "version": "",
- ]),
- "editorPluginInfo": .hash([
- "name": "Copilot for Xcode",
- "version": "",
- ]),
- ]))
- }
- }
-
- struct GetVersion: GitHubCopilotRequestType {
- struct Response: Codable {
- var version: String
- }
-
- var request: ClientRequest {
- .custom("getVersion", .hash([:]))
- }
- }
-
- struct CheckStatus: GitHubCopilotRequestType {
- struct Response: Codable {
- var status: GitHubCopilotAccountStatus
- }
-
- var request: ClientRequest {
- .custom("checkStatus", .hash([:]))
- }
- }
-
- struct SignInInitiate: GitHubCopilotRequestType {
- struct Response: Codable {
- var verificationUri: String
- var status: String
- var userCode: String
- var expiresIn: Int
- var interval: Int
- }
-
- var request: ClientRequest {
- .custom("signInInitiate", .hash([:]))
- }
- }
-
- struct SignInConfirm: GitHubCopilotRequestType {
- struct Response: Codable {
- var status: GitHubCopilotAccountStatus
- var user: String
- }
-
- var userCode: String
-
- var request: ClientRequest {
- .custom("signInConfirm", .hash([
- "userCode": .string(userCode),
- ]))
- }
- }
-
- struct SignOut: GitHubCopilotRequestType {
- struct Response: Codable {
- var status: GitHubCopilotAccountStatus
- }
-
- var request: ClientRequest {
- .custom("signOut", .hash([:]))
- }
- }
-
- struct GetCompletions: GitHubCopilotRequestType {
- struct Response: Codable {
- var completions: [CodeSuggestion]
- }
-
- var doc: GitHubCopilotDoc
-
- var request: ClientRequest {
- let data = (try? JSONEncoder().encode(doc)) ?? Data()
- let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:])
- return .custom("getCompletions", .hash([
- "doc": dict,
- ]))
- }
- }
-
- struct GetCompletionsCycling: GitHubCopilotRequestType {
- struct Response: Codable {
- var completions: [CodeSuggestion]
- }
-
- var doc: GitHubCopilotDoc
-
- var request: ClientRequest {
- let data = (try? JSONEncoder().encode(doc)) ?? Data()
- let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:])
- return .custom("getCompletionsCycling", .hash([
- "doc": dict,
- ]))
- }
- }
-
- struct GetPanelCompletions: GitHubCopilotRequestType {
- struct Response: Codable {
- var completions: [CodeSuggestion]
- }
-
- var doc: GitHubCopilotDoc
-
- var request: ClientRequest {
- let data = (try? JSONEncoder().encode(doc)) ?? Data()
- let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:])
- return .custom("getPanelCompletions", .hash([
- "doc": dict,
- ]))
- }
- }
-
- struct NotifyAccepted: GitHubCopilotRequestType {
- struct Response: Codable {}
-
- var completionUUID: String
-
- var request: ClientRequest {
- .custom("notifyAccepted", .hash([
- "uuid": .string(completionUUID),
- ]))
- }
- }
-
- struct NotifyRejected: GitHubCopilotRequestType {
- struct Response: Codable {}
-
- var completionUUIDs: [String]
-
- var request: ClientRequest {
- .custom("notifyRejected", .hash([
- "uuids": .array(completionUUIDs.map(JSONValue.string)),
- ]))
- }
- }
-}
-
diff --git a/Core/Sources/GitHubCopilotService/GitHubCopilotService.swift b/Core/Sources/GitHubCopilotService/GitHubCopilotService.swift
deleted file mode 100644
index 3c4a96b2..00000000
--- a/Core/Sources/GitHubCopilotService/GitHubCopilotService.swift
+++ /dev/null
@@ -1,414 +0,0 @@
-import Foundation
-import LanguageClient
-import LanguageServerProtocol
-import Logger
-import Preferences
-import SuggestionModel
-import XPCShared
-
-public protocol GitHubCopilotAuthServiceType {
- func checkStatus() async throws -> GitHubCopilotAccountStatus
- func signInInitiate() async throws -> (verificationUri: String, userCode: String)
- func signInConfirm(userCode: String) async throws
- -> (username: String, status: GitHubCopilotAccountStatus)
- func signOut() async throws -> GitHubCopilotAccountStatus
- func version() async throws -> String
-}
-
-public protocol GitHubCopilotSuggestionServiceType {
- func getCompletions(
- fileURL: URL,
- content: String,
- cursorPosition: CursorPosition,
- tabSize: Int,
- indentSize: Int,
- usesTabsForIndentation: Bool,
- ignoreSpaceOnlySuggestions: Bool,
- ignoreTrailingNewLinesAndSpaces: Bool
- ) async throws -> [CodeSuggestion]
- func notifyAccepted(_ completion: CodeSuggestion) async
- func notifyRejected(_ completions: [CodeSuggestion]) async
- func notifyOpenTextDocument(fileURL: URL, content: String) async throws
- func notifyChangeTextDocument(fileURL: URL, content: String) async throws
- func notifyCloseTextDocument(fileURL: URL) async throws
- func notifySaveTextDocument(fileURL: URL) async throws
- func cancelRequest() async
- func terminate() async
-}
-
-protocol GitHubCopilotLSP {
- func sendRequest(_ endpoint: E) async throws -> E.Response
- func sendNotification(_ notif: ClientNotification) async throws
-}
-
-enum GitHubCopilotError: Error, LocalizedError {
- case languageServerNotInstalled
-
- var errorDescription: String? {
- switch self {
- case .languageServerNotInstalled:
- return "Language server is not installed."
- }
- }
-}
-
-public class GitHubCopilotBaseService {
- let projectRootURL: URL
- var server: GitHubCopilotLSP
- var localProcessServer: CopilotLocalProcessServer?
-
- init(designatedServer: GitHubCopilotLSP) {
- projectRootURL = URL(fileURLWithPath: "/")
- server = designatedServer
- }
-
- init(projectRootURL: URL) throws {
- self.projectRootURL = projectRootURL
- let (server, localServer) = try {
- let urls = try GitHubCopilotBaseService.createFoldersIfNeeded()
- let executionParams: Process.ExecutionParameters
- let runner = UserDefaults.shared.value(for: \.runNodeWith)
-
- let agentJSURL = urls.executableURL.appendingPathComponent("copilot/dist/agent.js")
- guard FileManager.default.fileExists(atPath: agentJSURL.path) else {
- throw GitHubCopilotError.languageServerNotInstalled
- }
-
- switch runner {
- case .bash:
- let nodePath = UserDefaults.shared.value(for: \.nodePath)
- let command = [
- nodePath.isEmpty ? "node" : nodePath,
- "\"\(agentJSURL.path)\"",
- "--stdio",
- ].joined(separator: " ")
- executionParams = {
- Process.ExecutionParameters(
- path: "/bin/bash",
- arguments: ["-i", "-l", "-c", command],
- environment: [:],
- currentDirectoryURL: urls.supportURL
- )
- }()
- case .shell:
- let shell = ProcessInfo.processInfo.shellExecutablePath
- let nodePath = UserDefaults.shared.value(for: \.nodePath)
- let command = [
- nodePath.isEmpty ? "node" : nodePath,
- "\"\(agentJSURL.path)\"",
- "--stdio",
- ].joined(separator: " ")
- executionParams = {
- Process.ExecutionParameters(
- path: shell,
- arguments: ["-i", "-l", "-c", command],
- environment: [:],
- currentDirectoryURL: urls.supportURL
- )
- }()
- case .env:
- let userEnvPath = "/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
- executionParams = {
- let nodePath = UserDefaults.shared.value(for: \.nodePath)
- return Process.ExecutionParameters(
- path: "/usr/bin/env",
- arguments: [
- nodePath.isEmpty ? "node" : nodePath,
- agentJSURL.path,
- "--stdio",
- ],
- environment: [
- "PATH": userEnvPath,
- ],
- currentDirectoryURL: urls.supportURL
- )
- }()
- }
- let localServer = CopilotLocalProcessServer(executionParameters: executionParams)
-
- localServer.logMessages = UserDefaults.shared.value(for: \.gitHubCopilotVerboseLog)
- localServer.notificationHandler = { _, respond in
- respond(.timeout)
- }
- let server = InitializingServer(server: localServer)
-
- server.initializeParamsProvider = {
- let capabilities = ClientCapabilities(
- workspace: nil,
- textDocument: nil,
- window: nil,
- general: nil,
- experimental: nil
- )
-
- return InitializeParams(
- processId: Int(ProcessInfo.processInfo.processIdentifier),
- clientInfo: .init(
- name: Bundle.main
- .object(forInfoDictionaryKey: "HOST_APP_NAME") as? String
- ?? "Copilot for Xcode"
- ),
- locale: nil,
- rootPath: projectRootURL.path,
- rootUri: projectRootURL.path,
- initializationOptions: nil,
- capabilities: capabilities,
- trace: .off,
- workspaceFolders: nil
- )
- }
-
- return (server, localServer)
- }()
-
- self.server = server
- localProcessServer = localServer
-
- Task {
- try await server.sendRequest(GitHubCopilotRequest.SetEditorInfo())
- }
- }
-
- public static func createFoldersIfNeeded() throws -> (
- applicationSupportURL: URL,
- gitHubCopilotURL: URL,
- executableURL: URL,
- supportURL: URL
- ) {
- guard let supportURL = FileManager.default.urls(
- for: .applicationSupportDirectory,
- in: .userDomainMask
- ).first?.appendingPathComponent(
- Bundle.main
- .object(forInfoDictionaryKey: "APPLICATION_SUPPORT_FOLDER") as! String
- ) else {
- throw CancellationError()
- }
-
- if !FileManager.default.fileExists(atPath: supportURL.path) {
- try? FileManager.default
- .createDirectory(at: supportURL, withIntermediateDirectories: false)
- }
- let gitHubCopilotFolderURL = supportURL.appendingPathComponent("GitHub Copilot")
- if !FileManager.default.fileExists(atPath: gitHubCopilotFolderURL.path) {
- try? FileManager.default
- .createDirectory(at: gitHubCopilotFolderURL, withIntermediateDirectories: false)
- }
- let supportFolderURL = gitHubCopilotFolderURL.appendingPathComponent("support")
- if !FileManager.default.fileExists(atPath: supportFolderURL.path) {
- try? FileManager.default
- .createDirectory(at: supportFolderURL, withIntermediateDirectories: false)
- }
- let executableFolderURL = gitHubCopilotFolderURL.appendingPathComponent("executable")
- if !FileManager.default.fileExists(atPath: executableFolderURL.path) {
- try? FileManager.default
- .createDirectory(at: executableFolderURL, withIntermediateDirectories: false)
- }
-
- return (supportURL, gitHubCopilotFolderURL, executableFolderURL, supportFolderURL)
- }
-}
-
-public final class GitHubCopilotAuthService: GitHubCopilotBaseService,
- GitHubCopilotAuthServiceType
-{
- public init() throws {
- let home = FileManager.default.homeDirectoryForCurrentUser
- try super.init(projectRootURL: home)
- }
-
- public func checkStatus() async throws -> GitHubCopilotAccountStatus {
- try await server.sendRequest(GitHubCopilotRequest.CheckStatus()).status
- }
-
- public func signInInitiate() async throws -> (verificationUri: String, userCode: String) {
- let result = try await server.sendRequest(GitHubCopilotRequest.SignInInitiate())
- return (result.verificationUri, result.userCode)
- }
-
- public func signInConfirm(userCode: String) async throws
- -> (username: String, status: GitHubCopilotAccountStatus)
- {
- let result = try await server
- .sendRequest(GitHubCopilotRequest.SignInConfirm(userCode: userCode))
- return (result.user, result.status)
- }
-
- public func signOut() async throws -> GitHubCopilotAccountStatus {
- try await server.sendRequest(GitHubCopilotRequest.SignOut()).status
- }
-
- public func version() async throws -> String {
- try await server.sendRequest(GitHubCopilotRequest.GetVersion()).version
- }
-}
-
-@globalActor public enum GitHubCopilotSuggestionActor {
- public actor TheActor {}
- public static let shared = TheActor()
-}
-
-@GitHubCopilotSuggestionActor
-public final class GitHubCopilotSuggestionService: GitHubCopilotBaseService,
- GitHubCopilotSuggestionServiceType
-{
- private var ongoingTasks = Set>()
-
- override public init(projectRootURL: URL = URL(fileURLWithPath: "/")) throws {
- try super.init(projectRootURL: projectRootURL)
- }
-
- override init(designatedServer: GitHubCopilotLSP) {
- super.init(designatedServer: designatedServer)
- }
-
- public func getCompletions(
- fileURL: URL,
- content: String,
- cursorPosition: CursorPosition,
- tabSize: Int,
- indentSize: Int,
- usesTabsForIndentation: Bool,
- ignoreSpaceOnlySuggestions: Bool,
- ignoreTrailingNewLinesAndSpaces: Bool
- ) async throws -> [CodeSuggestion] {
- let languageId = languageIdentifierFromFileURL(fileURL)
-
- let relativePath = {
- let filePath = fileURL.path
- let rootPath = projectRootURL.path
- if let range = filePath.range(of: rootPath),
- range.lowerBound == filePath.startIndex
- {
- let relativePath = filePath.replacingCharacters(
- in: filePath.startIndex..(_ endpoint: E) async throws -> E.Response {
- try await sendRequest(endpoint.request)
- }
-}
-
diff --git a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManagementView.swift b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManagementView.swift
index 101c4e49..bc6c910e 100644
--- a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManagementView.swift
+++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManagementView.swift
@@ -1,60 +1,68 @@
import ComposableArchitecture
+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
+ if #available(macOS 13.0, *) {
+ view.listRowSeparator(.hidden).listSectionSeparator(.hidden)
+ } else {
+ 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
@@ -64,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)
}
}
@@ -120,7 +129,7 @@ class APIKeyManagementView_Preview: PreviewProvider {
initialState: .init(
availableAPIKeyNames: ["test1", "test2"]
),
- reducer: APIKeyManagement()
+ reducer: { APIKeyManagement() }
)
)
}
@@ -131,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 d43793c0..f0c673e5 100644
--- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift
+++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift
@@ -5,23 +5,37 @@ import Keychain
import OpenAIService
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 = ""
+ 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 }
var availableModelNames: [String] = []
var availableAPIKeys: [String] = []
var isTesting = false
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 {
@@ -34,21 +48,61 @@ struct ChatModelEdit: ReducerProtocol {
case testSucceeded(String)
case testFailed(String)
case checkSuggestedMaxTokens
+ case selectModelFormat(ModelFormat)
case apiKeySelection(APIKeySelection.Action)
case baseURLSelection(BaseURLSelection.Action)
}
- @Dependency(\.toast) var toast
+ 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 {
+ toast($0, $1, "ChatModelEdit")
+ }
+ }
+
@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()
}
@@ -69,28 +123,37 @@ struct ChatModelEdit: ReducerProtocol {
case .testButtonClicked:
guard !state.isTesting else { return .none }
state.isTesting = true
- let model = ChatModel(
- id: state.id,
- name: state.name,
- format: state.format,
- info: .init(
- apiKeyName: state.apiKeyName,
- baseURL: state.baseURL,
- maxTokens: state.maxTokens,
- supportsFunctionCalling: state.supportsFunctionCalling,
- modelName: state.modelName
- )
- )
+ let model = ChatModel(state: state)
return .run { send in
do {
- let reply =
- try await ChatGPTService(
- configuration: UserPreferenceChatGPTConfiguration()
- .overriding {
- $0.model = model
- }
- ).sendAndWait(content: "Hello")
- await send(.testSucceeded(reply ?? "No Message"))
+ 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))
}
@@ -98,12 +161,12 @@ struct ChatModelEdit: ReducerProtocol {
case let .testSucceeded(message):
state.isTesting = false
- toast(message, .info)
+ toast(message.trimmingCharacters(in: .whitespacesAndNewlines), .info)
return .none
case let .testFailed(message):
state.isTesting = false
- toast(message, .error)
+ toast(message.trimmingCharacters(in: .whitespacesAndNewlines), .error)
return .none
case .refreshAvailableModelNames:
@@ -114,28 +177,90 @@ struct ChatModelEdit: ReducerProtocol {
return .none
case .checkSuggestedMaxTokens:
- guard state.format == .openAI,
- let knownModel = ChatGPTModel(rawValue: state.modelName)
- else {
+ switch state.format {
+ case .openAI:
+ if let knownModel = ChatGPTModel(rawValue: state.modelName) {
+ state.suggestedMaxTokens = knownModel.maxToken
+ } else {
+ state.suggestedMaxTokens = nil
+ }
+ return .none
+ case .googleAI:
+ if let knownModel = GoogleGenerativeAIModel(rawValue: state.modelName) {
+ state.suggestedMaxTokens = knownModel.maxToken
+ } else {
+ state.suggestedMaxTokens = nil
+ }
+ return .none
+ case .claude:
+ if let knownModel = ClaudeChatCompletionsService
+ .KnownModel(rawValue: state.modelName)
+ {
+ state.suggestedMaxTokens = knownModel.contextWindow
+ } else {
+ 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
}
- state.suggestedMaxTokens = knownModel.maxToken
+
+ 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)
}
@@ -147,24 +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,
- apiKeySelection: .init(
- apiKeyName: model.info.apiKeyName,
- apiKeyManagement: .init(availableAPIKeyNames: [model.info.apiKeyName])
- ),
- baseURLSelection: .init(baseURL: model.info.baseURL)
- )
- }
-}
-
extension ChatModel {
init(state: ChatModelEdit.State) {
self.init(
@@ -174,11 +281,62 @@ extension ChatModel {
info: .init(
apiKeyName: state.apiKeyName,
baseURL: state.baseURL.trimmingCharacters(in: .whitespacesAndNewlines),
+ isFullURL: state.isFullURL,
maxTokens: state.maxTokens,
- supportsFunctionCalling: state.supportsFunctionCalling,
- modelName: state.modelName.trimmingCharacters(in: .whitespacesAndNewlines)
+ supportsFunctionCalling: {
+ switch state.format {
+ case .googleAI, .ollama, .claude:
+ return false
+ case .azureOpenAI, .openAI, .openAICompatible, .gitHubCopilot:
+ return state.supportsFunctionCalling
+ }
+ }(),
+ supportsImage: state.supportsImages,
+ modelName: state.modelName
+ .trimmingCharacters(in: .whitespacesAndNewlines),
+ openAIInfo: .init(
+ organizationID: state.openAIOrganizationID,
+ projectID: state.openAIProjectID
+ ),
+ ollamaInfo: .init(keepAlive: state.ollamaKeepAlive),
+ googleGenerativeAIInfo: .init(apiVersion: state.apiVersion),
+ openAICompatibleInfo: .init(
+ enforceMessageOrder: state.enforceMessageOrder,
+ 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 4a815566..d16b7556 100644
--- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift
+++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift
@@ -1,283 +1,660 @@
import AIModel
import ComposableArchitecture
+import OpenAIService
import Preferences
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:
+ GoogleAIForm(store: store)
+ case .ollama:
+ OllamaForm(store: store)
+ case .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()
+ 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
+ }
+ }())
- Button("Cancel") {
- store.send(.cancelButtonClicked)
- }
- .keyboardShortcut(.cancelAction)
+ Spacer()
- Button(action: { store.send(.saveButtonClicked) }) {
- Text("Save")
+ Button("Cancel") {
+ store.send(.cancelButtonClicked)
+ }
+ .keyboardShortcut(.cancelAction)
+
+ Button(action: { store.send(.saveButtonClicked) }) {
+ Text("Save")
+ }
+ .keyboardShortcut(.defaultAction)
}
- .keyboardShortcut(.defaultAction)
+ .padding()
}
- .padding()
}
- }
- .textFieldStyle(.roundedBorder)
- .onAppear {
- store.send(.appear)
+ .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)
+ 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(prompt: Text?) -> some View {
- BaseURLPicker(
- prompt: prompt,
- store: store.scope(
- state: \.baseURLSelection,
- action: ChatModelEdit.Action.baseURLSelection
- )
- )
- }
+ struct BaseURLTextField: View {
+ let store: StoreOf
+ var title: String = "Base URL"
+ let prompt: Text?
+ @ViewBuilder var trailingContent: () -> V
- 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()
+ var body: some View {
+ WithPerceptionTracking {
+ BaseURLPicker(
+ title: title,
+ prompt: prompt,
+ store: store.scope(
+ state: \.baseURLSelection,
+ action: \.baseURLSelection
+ ),
+ trailingContent: trailingContent
+ )
+ }
}
}
- struct MaxTokensTextField: Equatable {
- @BindingViewState var maxTokens: Int
- var suggestedMaxTokens: Int?
- }
+ struct SupportsFunctionCallingToggle: View {
+ @Perception.Bindable var store: StoreOf
+ var body: some View {
+ WithPerceptionTracking {
+ Toggle(
+ "Supports Function Calling",
+ isOn: $store.supportsFunctionCalling
+ )
- var maxTokensTextField: some View {
- WithViewStore(
- store,
- observe: {
- MaxTokensTextField(
- maxTokens: $0.$maxTokens,
- suggestedMaxTokens: $0.suggestedMaxTokens
+ 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()
}
- ) { 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
+ }
+ }
+
+ struct MaxTokensTextField: View {
+ @Perception.Bindable var store: StoreOf
+
+ 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("Max Tokens (Including Reply)")
- .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"))
- 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")
}
+ .keyboardShortcut(.defaultAction)
+ }
+ .padding()
+ .frame(width: 600, height: 500)
+ .background(Color(nsColor: .windowBackgroundColor))
+ }
+ }
+ }
+ }
+
+ struct CustomHeaderEdit: View {
+ @Perception.Bindable var store: StoreOf
+ @State private var isEditing = false
+
+ var body: some View {
+ Button("Custom Headers") {
+ isEditing = true
+ }
+ .sheet(isPresented: $isEditing) {
+ WithPerceptionTracking {
+ CustomHeaderSettingsView(headers: $store.customHeaders)
+ }
+ }
+ }
+ }
+
+ struct OpenAIForm: View {
+ @Perception.Bindable var store: StoreOf
+ var body: some View {
+ 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)
+ }
+
+ MaxTokensTextField(store: store)
+ SupportsFunctionCallingToggle(store: store)
+
+ TextField(text: $store.openAIOrganizationID, prompt: Text("Optional")) {
+ Text("Organization ID")
+ }
+
+ TextField(text: $store.openAIProjectID, prompt: Text("Optional")) {
+ Text("Project ID")
+ }
+
+ Toggle(isOn: $store.supportsImages) {
+ Text("Supports Images")
+ }
+
+ VStack(alignment: .leading, spacing: 8) {
+ Text(Image(systemName: "exclamationmark.triangle.fill")) + Text(
+ " To get an API key, please visit [https://platform.openai.com/api-keys](https://platform.openai.com/api-keys)"
+ )
+
+ 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)
+ }
}
+ }
+
+ 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)
+
+ TextField("Deployment Name", text: $store.modelName)
- maxTokensTextField
- supportsFunctionCallingToggle
+ MaxTokensTextField(store: store)
+ SupportsFunctionCallingToggle(store: store)
+
+ Toggle(isOn: $store.supportsImages) {
+ Text("Supports Images")
+ }
+ }
+ }
}
- @ViewBuilder
- var azureOpenAI: some View {
- baseURLTextField(prompt: Text("https://xxxx.openai.azure.com"))
- apiKeyNamePicker
+ 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(store: store)
+
+ TextField("Model Name", text: $store.modelName)
- WithViewStore(
- store,
- removeDuplicates: { $0.modelName == $1.modelName }
- ) { viewStore in
- TextField("Deployment Name", text: viewStore.$modelName)
+ MaxTokensTextField(store: store)
+ SupportsFunctionCallingToggle(store: store)
+
+ Toggle(isOn: $store.enforceMessageOrder) {
+ Text("Enforce message order to be user/assistant alternated")
+ }
+
+ Toggle(isOn: $store.openAICompatibleSupportsMultipartMessageContent) {
+ Text("Support multi-part message content")
+ }
+
+ Toggle(isOn: $store.requiresBeginWithUserMessage) {
+ Text("Requires the first message to be from the user")
+ }
+
+ Toggle(isOn: $store.supportsImages) {
+ Text("Supports Images")
+ }
+ }
}
+ }
+
+ struct GoogleAIForm: View {
+ @Perception.Bindable var store: StoreOf
+
+ 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(store: store)
+
+ TextField("API Version", text: $store.apiVersion, prompt: Text("v1"))
+
+ Toggle(isOn: $store.supportsImages) {
+ Text("Supports Images")
+ }
+ }
+ }
+ }
+
+ struct OllamaForm: View {
+ @Perception.Bindable var store: StoreOf
+
+ var body: some View {
+ WithPerceptionTracking {
+ BaseURLTextField(store: store, prompt: Text("http://127.0.0.1:11434")) {
+ Text("/api/chat")
+ }
+
+ ApiKeyNamePicker(store: store)
+
+ TextField("Model Name", text: $store.modelName)
+
+ MaxTokensTextField(store: store)
+
+ TextField(text: $store.ollamaKeepAlive, prompt: Text("Default Value")) {
+ Text("Keep Alive")
+ }
- maxTokensTextField
- supportsFunctionCallingToggle
+ 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)
+ }
+ }
}
- @ViewBuilder
- var openAICompatible: some View {
- baseURLTextField(prompt: Text("https://"))
- apiKeyNamePicker
+ 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(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)
+ }
+ }
+ )
+ .frame(width: 20)
+ }
+
+ MaxTokensTextField(store: store)
+
+ Toggle(isOn: $store.supportsImages) {
+ Text("Supports Images")
+ }
- WithViewStore(
- store,
- removeDuplicates: { $0.modelName == $1.modelName }
- ) { viewStore in
- TextField("Model Name", text: viewStore.$modelName)
+ VStack(alignment: .leading, spacing: 8) {
+ Text(Image(systemName: "exclamationmark.triangle.fill")) + Text(
+ " For more details, please visit [https://anthropic.com](https://anthropic.com)."
+ )
+ }
+ .padding(.vertical)
+ }
}
+ }
+
+ struct GitHubCopilotForm: View {
+ @Perception.Bindable var store: StoreOf
+
+ var body: some View {
+ WithPerceptionTracking {
+ #warning("Todo: use the old picker and update the context window limit.")
+ GitHubCopilotModelPicker(
+ title: "Model Name",
+ hasDefaultModel: false,
+ gitHubCopilotModelId: $store.modelName
+ )
- maxTokensTextField
- supportsFunctionCallingToggle
+ MaxTokensTextField(store: store)
+ SupportsFunctionCallingToggle(store: store)
+
+ Toggle(isOn: $store.enforceMessageOrder) {
+ Text("Enforce message order to be user/assistant alternated")
+ }
+
+ Toggle(isOn: $store.openAICompatibleSupportsMultipartMessageContent) {
+ Text("Support multi-part message content")
+ }
+
+ Toggle(isOn: $store.supportsImages) {
+ Text("Supports Images")
+ }
+
+ VStack(alignment: .leading, spacing: 8) {
+ Text(Image(systemName: "exclamationmark.triangle.fill")) + Text(
+ " Please login in the GitHub Copilot settings to use the model."
+ )
+
+ Text(Image(systemName: "exclamationmark.triangle.fill")) + Text(
+ " This will call the APIs directly, which may not be allowed by GitHub. But it's used in other popular apps like Zed."
+ )
+ }
+ .dynamicHeightTextInFormWorkaround()
+ .padding(.vertical)
+ }
+ }
}
}
-class ChatModelManagementView_Editing_Previews: PreviewProvider {
- static var previews: some View {
- ChatModelEditView(
- store: .init(
- initialState: .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"
- )
- )),
- reducer: ChatModelEdit()
- )
+#Preview("OpenAI") {
+ ChatModelEditView(
+ store: .init(
+ initialState: ChatModel(
+ id: "3",
+ name: "Test Model 3",
+ format: .openAI,
+ info: .init(
+ apiKeyName: "key",
+ baseURL: "apple.com",
+ maxTokens: 3000,
+ supportsFunctionCalling: false,
+ modelName: "gpt-3.5-turbo"
+ )
+ ).toState(),
+ reducer: { ChatModelEdit() }
)
- }
+ )
+}
+
+#Preview("OpenAI Compatible") {
+ ChatModelEditView(
+ store: .init(
+ initialState: ChatModel(
+ id: "3",
+ name: "Test Model 3",
+ format: .openAICompatible,
+ info: .init(
+ apiKeyName: "key",
+ baseURL: "apple.com",
+ isFullURL: false,
+ maxTokens: 3000,
+ supportsFunctionCalling: false,
+ modelName: "gpt-3.5-turbo"
+ )
+ ).toState(),
+ reducer: { ChatModelEdit() }
+ )
+ )
}
diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagement.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagement.swift
index 182536c1..64eadd57 100644
--- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagement.swift
+++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagement.swift
@@ -10,6 +10,10 @@ extension ChatModel: ManageableAIModel {
case .openAI: return "OpenAI"
case .azureOpenAI: return "Azure OpenAI"
case .openAICompatible: return "OpenAI Compatible"
+ case .googleAI: return "Google Generative AI"
+ case .ollama: return "Ollama"
+ case .claude: return "Claude"
+ case .gitHubCopilot: return "GitHub Copilot"
}
}
@@ -34,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 }
}
@@ -58,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:
@@ -86,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):
@@ -131,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 cac7184f..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(minWidth: 400)
- }
+ 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 7c7b12cb..e60af2a8 100644
--- a/Core/Sources/HostApp/AccountSettings/CodeiumView.swift
+++ b/Core/Sources/HostApp/AccountSettings/CodeiumView.swift
@@ -1,5 +1,6 @@
import CodeiumService
import Foundation
+import SharedUIComponents
import SwiftUI
struct CodeiumView: View {
@@ -13,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(
@@ -55,7 +60,7 @@ struct CodeiumView: View {
func refreshInstallationStatus() {
Task { @MainActor in
- installationStatus = installationManager.checkInstallation()
+ installationStatus = await installationManager.checkInstallation()
}
}
@@ -134,7 +139,7 @@ struct CodeiumView: View {
var body: some View {
VStack(alignment: .leading) {
- VStack(alignment: .leading) {
+ SubSection(title: Text("Codeium Language Server")) {
switch viewModel.installationStatus {
case .notInstalled:
HStack {
@@ -146,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
@@ -184,12 +189,6 @@ struct CodeiumView: View {
}
}
}
- .padding(8)
- .frame(maxWidth: .infinity, alignment: .leading)
- .overlay {
- RoundedRectangle(cornerRadius: 8)
- .stroke(Color(nsColor: .separatorColor), style: .init(lineWidth: 1))
- }
.sheet(isPresented: $isSignInPanelPresented) {
CodeiumSignInView(viewModel: viewModel, isPresented: $isSignInPanelPresented)
}
@@ -207,20 +206,22 @@ struct CodeiumView: View {
}
}
}
-
- Form {
- Toggle("Codeium Enterprise Mode", isOn: $viewModel.codeiumEnterpriseMode)
- TextField("Codeium Portal URL", text: $viewModel.codeiumPortalUrl)
- TextField("Codeium API URL", text: $viewModel.codeiumApiUrl)
+
+ SubSection(title: Text("Indexing")) {
+ Form {
+ Toggle("Enable Indexing", isOn: $viewModel.indexEnabled)
+ }
}
- .padding(8)
- .frame(maxWidth: .infinity, alignment: .leading)
- .overlay {
- RoundedRectangle(cornerRadius: 8)
- .stroke(Color(nsColor: .separatorColor), style: .init(lineWidth: 1))
+
+ SubSection(title: Text("Enterprise")) {
+ Form {
+ Toggle("Codeium Enterprise Mode", isOn: $viewModel.codeiumEnterpriseMode)
+ TextField("Codeium Portal URL", text: $viewModel.codeiumPortalUrl)
+ TextField("Codeium API URL", text: $viewModel.codeiumApiUrl)
+ }
}
- Divider()
+ SettingsDivider("Advanced")
Form {
Toggle("Verbose Log", isOn: $viewModel.codeiumVerboseLog)
@@ -276,7 +277,9 @@ struct CodeiumSignInView: View {
}
}) {
Text(isGeneratingKey ? "Signing In.." : "Sign In")
- }.disabled(isGeneratingKey)
+ }
+ .disabled(isGeneratingKey)
+ .keyboardShortcut(.defaultAction)
}
}
.padding()
@@ -304,32 +307,34 @@ struct CodeiumView_Previews: PreviewProvider {
}
static var previews: some View {
- VStack(alignment: .leading, spacing: 8) {
- CodeiumView(viewModel: TestViewModel(
- isSignedIn: false,
- installationStatus: .notInstalled,
- installationStep: nil
- ))
-
- CodeiumView(viewModel: TestViewModel(
- isSignedIn: true,
- installationStatus: .installed("1.2.9"),
- installationStep: nil
- ))
-
- CodeiumView(viewModel: TestViewModel(
- isSignedIn: true,
- installationStatus: .outdated(current: "1.2.9", latest: "1.3.0"),
- installationStep: .downloading
- ))
-
- CodeiumView(viewModel: TestViewModel(
- isSignedIn: true,
- installationStatus: .unsupported(current: "1.5.9", latest: "1.3.0"),
- installationStep: .downloading
- ))
+ ScrollView {
+ VStack(alignment: .leading, spacing: 8) {
+ CodeiumView(viewModel: TestViewModel(
+ isSignedIn: false,
+ installationStatus: .notInstalled,
+ installationStep: nil
+ ))
+
+ CodeiumView(viewModel: TestViewModel(
+ isSignedIn: true,
+ installationStatus: .installed("1.2.9"),
+ installationStep: nil
+ ))
+
+ CodeiumView(viewModel: TestViewModel(
+ isSignedIn: true,
+ installationStatus: .outdated(current: "1.2.9", latest: "1.3.0", mandatory: true),
+ installationStep: .downloading
+ ))
+
+ CodeiumView(viewModel: TestViewModel(
+ isSignedIn: true,
+ installationStatus: .unsupported(current: "1.5.9", latest: "1.3.0"),
+ installationStep: .downloading
+ ))
+ }
+ .padding(8)
}
- .padding(8)
}
}
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 6b0d772b..f057be21 100644
--- a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift
+++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift
@@ -5,22 +5,29 @@ 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 = ""
+ 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 }
var availableModelNames: [String] = []
var availableAPIKeys: [String] = []
var isTesting = false
var suggestedMaxTokens: Int?
var apiKeySelection: APIKeySelection.State = .init()
var baseURLSelection: BaseURLSelection.State = .init()
+ var customHeaders: [ChatModel.Info.CustomHeaderInfo.HeaderField] = []
}
enum Action: Equatable, BindableAction {
@@ -32,22 +39,55 @@ 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
+ return {
+ toast($0, $1, "EmbeddingModelEdit")
+ }
+ }
- @Dependency(\.toast) var toast
@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()
}
@@ -68,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,
@@ -75,20 +116,35 @@ struct EmbeddingModelEdit: ReducerProtocol {
info: .init(
apiKeyName: state.apiKeyName,
baseURL: state.baseURL,
+ isFullURL: state.isFullURL,
maxTokens: state.maxTokens,
+ dimensions: dimensions,
modelName: state.modelName
)
)
return .run { send in
do {
- let tokenUsage =
- try await EmbeddingService(
- configuration: UserPreferenceEmbeddingConfiguration()
- .overriding {
- $0.model = model
- }
- ).embed(text: "Hello").usage.total_tokens
- await send(.testSucceeded("Used \(tokenUsage) tokens."))
+ let result = try await EmbeddingService(
+ configuration: UserPreferenceEmbeddingConfiguration()
+ .overriding {
+ $0.model = model
+ }
+ ).embed(text: "Hello")
+ 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))
}
@@ -119,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:
@@ -127,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)
}
@@ -145,23 +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,
- apiKeySelection: .init(
- apiKeyName: model.info.apiKeyName,
- apiKeyManagement: .init(availableAPIKeyNames: [model.info.apiKeyName])
- ),
- baseURLSelection: .init(baseURL: model.info.baseURL)
- )
- }
-}
-
extension EmbeddingModel {
init(state: EmbeddingModelEdit.State) {
self.init(
@@ -171,10 +238,35 @@ extension EmbeddingModel {
info: .init(
apiKeyName: state.apiKeyName,
baseURL: state.baseURL.trimmingCharacters(in: .whitespacesAndNewlines),
+ isFullURL: state.isFullURL,
maxTokens: state.maxTokens,
- modelName: state.modelName.trimmingCharacters(in: .whitespacesAndNewlines)
+ dimensions: state.dimensions,
+ modelName: state.modelName.trimmingCharacters(in: .whitespacesAndNewlines),
+ 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 c1162181..46f4effd 100644
--- a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift
+++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift
@@ -5,235 +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:
+ 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)
+ .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)
+ 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(prompt: Text?) -> some View {
- BaseURLPicker(
- prompt: prompt,
- store: store.scope(
- state: \.baseURLSelection,
- action: EmbeddingModelEdit.Action.baseURLSelection
- )
- )
- }
-
- struct MaxTokensTextField: Equatable {
- @BindingViewState var maxTokens: Int
- var suggestedMaxTokens: Int?
- }
+ struct BaseURLTextField: View {
+ let store: StoreOf
+ var title: String = "Base URL"
+ let prompt: Text?
+ @ViewBuilder var trailingContent: () -> V
- var maxTokensTextField: some View {
- WithViewStore(
- store,
- observe: {
- MaxTokensTextField(
- maxTokens: $0.$maxTokens,
- suggestedMaxTokens: $0.suggestedMaxTokens
+ var body: some View {
+ WithPerceptionTracking {
+ BaseURLPicker(
+ title: title,
+ prompt: prompt,
+ store: store.scope(
+ state: \.baseURLSelection,
+ action: \.baseURLSelection
+ ),
+ trailingContent: trailingContent
)
}
- ) { 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
+ }
+ }
+
+ struct MaxTokensTextField: View {
+ @Perception.Bindable var store: StoreOf
+
+ 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("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"))
- 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
}
- @ViewBuilder
- var azureOpenAI: some View {
- baseURLTextField(prompt: Text("https://xxxx.openai.azure.com"))
- 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)
+
+ TextField("Deployment Name", text: $store.modelName)
- WithViewStore(
- store,
- removeDuplicates: { $0.modelName == $1.modelName }
- ) { viewStore in
- TextField("Deployment Name", text: viewStore.$modelName)
+ MaxTokensTextField(store: store)
+ DimensionsTextField(store: store)
+ }
}
+ }
+
+ 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")
+ }
+ }
+
+ ApiKeyNamePicker(store: store)
+
+ TextField("Model Name", text: $store.modelName)
+
+ MaxTokensTextField(store: store)
+ DimensionsTextField(store: store)
- maxTokensTextField
+ Button("Custom Headers") {
+ isEditingCustomHeader.toggle()
+ }
+ }.sheet(isPresented: $isEditingCustomHeader) {
+ CustomHeaderSettingsView(headers: $store.customHeaders)
+ }
+ }
}
- @ViewBuilder
- var openAICompatible: some View {
- baseURLTextField(prompt: Text("https://"))
- 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")
+ }
+
+ ApiKeyNamePicker(store: store)
+
+ 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()
+ }
+
+ VStack(alignment: .leading, spacing: 8) {
+ Text(Image(systemName: "exclamationmark.triangle.fill")) + Text(
+ " For more details, please visit [https://ollama.com](https://ollama.com)."
+ )
+ }
+ .padding(.vertical)
+
+ }.sheet(isPresented: $isEditingCustomHeader) {
+ CustomHeaderSettingsView(headers: $store.customHeaders)
+ }
}
+ }
+
+ struct GitHubCopilotForm: View {
+ @Perception.Bindable var store: StoreOf
+ @State var isEditingCustomHeader = false
+
+ var body: some View {
+ WithPerceptionTracking {
+ TextField("Model Name", text: $store.modelName)
+ .overlay(alignment: .trailing) {
+ Picker(
+ "",
+ selection: $store.modelName,
+ content: {
+ if OpenAIEmbeddingModel(rawValue: store.modelName) == nil {
+ Text("Custom Model").tag(store.modelName)
+ }
+ ForEach(OpenAIEmbeddingModel.allCases, id: \.self) { model in
+ Text(model.rawValue).tag(model.rawValue)
+ }
+ }
+ )
+ .frame(width: 20)
+ }
+
+ MaxTokensTextField(store: store)
+ DimensionsTextField(store: store)
+
+ Button("Custom Headers") {
+ isEditingCustomHeader.toggle()
+ }
+
+ VStack(alignment: .leading, spacing: 8) {
+ Text(Image(systemName: "exclamationmark.triangle.fill")) + Text(
+ " Please login in the GitHub Copilot settings to use the model."
+ )
- maxTokensTextField
+ 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)
+ }
+ }
}
}
@@ -241,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,
@@ -251,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 eda907d3..156f58ac 100644
--- a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagement.swift
+++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagement.swift
@@ -10,6 +10,8 @@ extension EmbeddingModel: ManageableAIModel {
case .openAI: return "OpenAI"
case .azureOpenAI: return "Azure OpenAI"
case .openAICompatible: return "OpenAI Compatible"
+ case .ollama: return "Ollama"
+ case .gitHubCopilot: return "GitHub Copilot"
}
}
@@ -28,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 }
}
@@ -52,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:
@@ -80,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):
@@ -125,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 a71b356d..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(minWidth: 400)
- }
+ 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 d31b1fb8..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 {
@@ -19,10 +19,13 @@ struct GitHubCopilotView: View {
@AppStorage(\.gitHubCopilotProxyUsername) var gitHubCopilotProxyUsername
@AppStorage(\.gitHubCopilotProxyPassword) var gitHubCopilotProxyPassword
@AppStorage(\.gitHubCopilotUseStrictSSL) var gitHubCopilotUseStrictSSL
- @AppStorage(\.gitHubCopilotIgnoreTrailingNewLines)
- var gitHubCopilotIgnoreTrailingNewLines
+ @AppStorage(\.gitHubCopilotEnterpriseURI) var gitHubCopilotEnterpriseURI
+ @AppStorage(\.gitHubCopilotPretendIDEToBeVSCode) var pretendIDEToBeVSCode
@AppStorage(\.disableGitHubCopilotSettingsAutoRefreshOnAppear)
var disableGitHubCopilotSettingsAutoRefreshOnAppear
+ @AppStorage(\.gitHubCopilotLoadKeyChainCertificates)
+ var gitHubCopilotLoadKeyChainCertificates
+ @AppStorage(\.gitHubCopilotModelId) var gitHubCopilotModelId
init() {}
}
@@ -142,72 +145,71 @@ struct GitHubCopilotView: View {
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 8) {
- Form {
- TextField(
- text: $settings.nodePath,
- prompt: Text(
- "node"
+ SubSection(
+ title: Text("Node Settings"),
+ description: """
+ You may have to restart the extension app to apply the changes. To do so, simply close the helper app by clicking on the menu bar icon that looks like a tentacle, it will automatically restart as needed.
+ """
+ ) {
+ Form {
+ TextField(
+ text: $settings.nodePath,
+ prompt: Text(
+ "node"
+ )
+ ) {
+ Text("Path to Node (v22.0+)")
+ }
+
+ Text(
+ "Provide the path to the executable if it can't be found by the app, shim executable is not supported"
)
- ) {
- Text("Path to Node (v17+)")
- }
+ .lineLimit(10)
+ .foregroundColor(.secondary)
+ .font(.callout)
+ .dynamicHeightTextInFormWorkaround()
+
+ Picker(selection: $settings.runNodeWith) {
+ ForEach(NodeRunner.allCases, id: \.rawValue) { runner in
+ switch runner {
+ case .env:
+ Text("/usr/bin/env").tag(runner)
+ case .bash:
+ Text("/bin/bash -i -l").tag(runner)
+ case .shell:
+ Text("$SHELL -i -l").tag(runner)
+ }
+ }
+ } label: {
+ Text("Run Node with")
+ }
- Text(
- "Provide the path to the executable if it can't be found by the app, shim executable is not supported"
- )
- .lineLimit(10)
- .foregroundColor(.secondary)
- .font(.callout)
- .dynamicHeightTextInFormWorkaround()
-
- Picker(selection: $settings.runNodeWith) {
- ForEach(NodeRunner.allCases, id: \.rawValue) { runner in
- switch runner {
+ Group {
+ switch settings.runNodeWith {
case .env:
- Text("/usr/bin/env").tag(runner)
+ Text(
+ "PATH: `/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin`"
+ )
case .bash:
- Text("/bin/bash -i -l").tag(runner)
+ Text("PATH inherited from bash configurations.")
case .shell:
- Text("$SHELL -i -l").tag(runner)
+ Text("PATH inherited from $SHELL configurations.")
}
}
- } label: {
- Text("Run Node with")
- }
+ .lineLimit(10)
+ .foregroundColor(.secondary)
+ .font(.callout)
+ .dynamicHeightTextInFormWorkaround()
- Group {
- switch settings.runNodeWith {
- case .env:
- Text(
- "PATH: `/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin`"
- )
- case .bash:
- Text("PATH inherited from bash configurations.")
- case .shell:
- Text("PATH inherited from $SHELL configurations.")
+ Toggle(isOn: $settings.gitHubCopilotLoadKeyChainCertificates) {
+ Text("Load certificates in keychain")
}
}
- .lineLimit(10)
- .foregroundColor(.secondary)
- .font(.callout)
- .dynamicHeightTextInFormWorkaround()
-
- Spacer()
-
- Text("""
- You may have to restart the helper app to apply the changes. To do so, simply close the helper app by clicking on the menu bar icon that looks like a tentacle, it will automatically restart as needed.
- """)
- .lineLimit(6)
- .dynamicHeightTextInFormWorkaround()
- .foregroundColor(.secondary)
- }
- .padding(8)
- .overlay {
- RoundedRectangle(cornerRadius: 8)
- .stroke(Color(nsColor: .separatorColor), style: .init(lineWidth: 1))
}
- VStack(alignment: .leading) {
+ SubSection(
+ title: Text("GitHub Copilot Language Server")
+ ) {
HStack {
switch viewModel.installationStatus {
case .none:
@@ -218,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
@@ -259,53 +261,59 @@ struct GitHubCopilotView: View {
if isRunningAction {
ActivityIndicatorView()
}
- }
+ }
.opacity(isRunningAction ? 0.8 : 1)
.disabled(isRunningAction)
+
+ Button("Refresh configurations") {
+ refreshConfiguration()
+ }
+
+ Form {
+ GitHubCopilotModelPicker(
+ title: "Chat Model Name",
+ gitHubCopilotModelId: $settings.gitHubCopilotModelId
+ )
+ }
}
- .padding(8)
- .frame(maxWidth: .infinity, alignment: .leading)
- .overlay {
- RoundedRectangle(cornerRadius: 8)
- .stroke(Color(nsColor: .separatorColor), style: .init(lineWidth: 1))
+
+ SettingsDivider("Advanced")
+
+ Form {
+ Toggle("Verbose log", isOn: $settings.gitHubCopilotVerboseLog)
+ Toggle("Pretend IDE to be VSCode", isOn: $settings.pretendIDEToBeVSCode)
}
- Divider()
+ SettingsDivider("Enterprise")
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)
+ TextField(
+ text: $settings.gitHubCopilotEnterpriseURI,
+ prompt: Text("Leave it blank if non is available.")
+ ) {
+ Text("Auth provider URL")
+ }
}
- Divider()
+ SettingsDivider("Proxy")
Form {
TextField(
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()
@@ -349,7 +357,6 @@ struct GitHubCopilotView: View {
if status != .ok, status != .notSignedIn {
toast(
"GitHub Copilot status is not \"ok\". Please check if you have a valid GitHub Copilot subscription.",
-
.error
)
}
@@ -414,6 +421,25 @@ struct GitHubCopilotView: View {
}
}
}
+
+ func refreshConfiguration() {
+ NotificationCenter.default.post(
+ name: .gitHubCopilotShouldRefreshEditorInformation,
+ object: nil
+ )
+
+ Task {
+ let service = try getService()
+ do {
+ try await service.postNotification(
+ name: Notification.Name
+ .gitHubCopilotShouldRefreshEditorInformation.rawValue
+ )
+ } catch {
+ toast(error.localizedDescription, .error)
+ }
+ }
+ }
}
struct ActivityIndicatorView: NSViewRepresentable {
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 37bcac29..2c1fd2d7 100644
--- a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/AIModelManagementVIew.swift
+++ b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/AIModelManagementVIew.swift
@@ -1,6 +1,7 @@
import AIModel
import ComposableArchitecture
import PlusFeatureFlag
+import SharedUIComponents
import SwiftUI
protocol AIModelManagementAction {
@@ -19,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
{
@@ -38,76 +39,85 @@ 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, *) {
+ view.listRowSeparator(.hidden).listSectionSeparator(.hidden)
+ } else {
+ view
+ }
+ }
}
.removeBackground()
.listStyle(.plain)
.listRowInsets(EdgeInsets())
.overlay {
- if viewStore.state.models.isEmpty {
+ if store.models.isEmpty {
Text("No model found, please add a new one.")
.foregroundColor(.secondary)
}
@@ -222,22 +232,20 @@ class AIModelManagement_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() }
)
)
}
@@ -247,8 +255,8 @@ class AIModelManagement_Empty_Previews: PreviewProvider {
static var previews: some View {
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 47f5144a..9456946e 100644
--- a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLPicker.swift
+++ b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLPicker.swift
@@ -1,38 +1,60 @@
import ComposableArchitecture
import SwiftUI
-struct BaseURLPicker: View {
+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
- TextField("Base URL", text: viewStore.$baseURL, prompt: prompt)
- .overlay(alignment: .trailing) {
- Picker(
- "",
- selection: viewStore.$baseURL,
- content: {
- if !viewStore.state.availableBaseURLs
- .contains(viewStore.state.baseURL),
- !viewStore.state.baseURL.isEmpty
- {
- Text("Custom Value").tag(viewStore.state.baseURL)
- }
+ WithPerceptionTracking {
+ HStack {
+ TextField(title, text: $store.baseURL, prompt: prompt)
+ .overlay(alignment: .trailing) {
+ Picker(
+ "",
+ selection: $store.baseURL,
+ content: {
+ if !store.availableBaseURLs
+ .contains(store.baseURL),
+ !store.baseURL.isEmpty
+ {
+ Text("Custom Value").tag(store.baseURL)
+ }
- Text("Empty (Default Value)").tag("")
+ Text("Empty (Default Value)").tag("")
- ForEach(viewStore.state.availableBaseURLs, id: \.self) { baseURL in
- Text(baseURL).tag(baseURL)
+ ForEach(store.availableBaseURLs, id: \.self) { baseURL in
+ Text(baseURL).tag(baseURL)
+ }
}
- }
- )
- .frame(width: 20)
- }
- .onAppear {
- viewStore.send(.appear)
- }
+ )
+ .frame(width: 20)
+ }
+
+ trailingContent()
+ .foregroundStyle(.secondary)
+ }
+ .onAppear {
+ store.send(.appear)
+ }
}
}
}
+extension BaseURLPicker where TrailingContent == EmptyView {
+ init(
+ title: String,
+ prompt: Text? = nil,
+ store: StoreOf
+ ) {
+ self.init(
+ title: title,
+ prompt: prompt,
+ store: store,
+ trailingContent: { EmptyView() }
+ )
+ }
+}
+
diff --git a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLSelection.swift b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLSelection.swift
index c4cd4b96..502d79a7 100644
--- a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLSelection.swift
+++ b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLSelection.swift
@@ -3,9 +3,12 @@ import Foundation
import Preferences
import SwiftUI
-struct BaseURLSelection: ReducerProtocol {
+@Reducer
+struct BaseURLSelection {
+ @ObservableState
struct State: Equatable {
- @BindingState var baseURL: String = ""
+ var baseURL: String = ""
+ var isFullURL: Bool = false
var availableBaseURLs: [String] = []
}
@@ -18,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 880704c4..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?
}
@@ -17,11 +19,14 @@ struct CustomCommandFeature: ReducerProtocol {
case editCommand(CustomCommand)
case editCustomCommand(EditCustomCommand.Action)
case deleteCommand(CustomCommand)
+ case exportCommand(CustomCommand)
+ case importCommand(at: URL)
+ case importCommandClicked
}
@Dependency(\.toast) var toast
- var body: some ReducerProtocol {
+ var body: some ReducerOf {
Reduce { state, action in
switch action {
case .createNewCommand:
@@ -49,8 +54,77 @@ struct CustomCommandFeature: ReducerProtocol {
return .none
case .editCustomCommand:
return .none
+ case let .exportCommand(command):
+ return .run { _ in
+ do {
+ let data = try JSONEncoder().encode(command)
+ let filename = "CustomCommand-\(command.name).json"
+
+ let url = await withCheckedContinuation { continuation in
+ Task { @MainActor in
+ let panel = NSSavePanel()
+ panel.canCreateDirectories = true
+ panel.nameFieldStringValue = filename
+ let result = await panel.begin()
+ switch result {
+ case .OK:
+ continuation.resume(returning: panel.url)
+ default:
+ continuation.resume(returning: nil)
+ }
+ }
+ }
+
+ if let url {
+ try data.write(to: url)
+ toast("Saved!", .info)
+ }
+
+ } catch {
+ toast(error.localizedDescription, .error)
+ }
+ }
+
+ case let .importCommand(url):
+ if !isFeatureAvailable(\.unlimitedCustomCommands),
+ settings.customCommands.count >= 10
+ {
+ toast("Upgrade to Plus to add more commands", .info)
+ return .none
+ }
+
+ do {
+ let data = try Data(contentsOf: url)
+ var command = try JSONDecoder().decode(CustomCommand.self, from: data)
+ command.commandId = UUID().uuidString
+ settings.customCommands.append(command)
+ toast("Imported custom command \(command.name)!", .info)
+ } catch {
+ toast("Failed to import command: \(error.localizedDescription)", .error)
+ }
+ return .none
+
+ case .importCommandClicked:
+ return .run { send in
+ let url = await withCheckedContinuation { continuation in
+ Task { @MainActor in
+ let panel = NSOpenPanel()
+ panel.allowedContentTypes = [.json]
+ let result = await panel.begin()
+ if result == .OK {
+ continuation.resume(returning: panel.url)
+ } else {
+ continuation.resume(returning: nil)
+ }
+ }
+ }
+
+ if let url {
+ await send(.importCommand(at: url))
+ }
+ }
}
- }.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 52f6834f..033b9850 100644
--- a/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift
+++ b/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift
@@ -2,7 +2,9 @@ import ComposableArchitecture
import MarkdownUI
import PlusFeatureFlag
import Preferences
+import SharedUIComponents
import SwiftUI
+import Toast
extension List {
@ViewBuilder
@@ -19,9 +21,7 @@ extension List {
let customCommandStore = StoreOf(
initialState: .init(),
- reducer: CustomCommandFeature(
- settings: .init()
- )
+ reducer: { CustomCommandFeature(settings: .init()) }
)
struct CustomCommandView: View {
@@ -41,133 +41,185 @@ 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: \.name) { command in
- CommandButton(store: store, command: command)
- }
- .onMove(perform: { indices, newOffset in
- settings.customCommands.move(fromOffsets: indices, toOffset: newOffset)
- })
- }
- .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)
+ 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)")
+ }
+ }
+ .buttonStyle(.plain)
+ .padding()
+ .contextMenu {
+ Button("Import") {
+ store.send(.importCommandClicked)
+ }
+ }
+ }
+ .onDrop(of: [.json], delegate: FileDropDelegate(store: store, toast: toast))
}
}
- .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)")
- }
+ }
+
+ struct FileDropDelegate: DropDelegate {
+ let store: StoreOf
+ let toast: (String, ToastType) -> Void
+ 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)
+ }
+ }
+ }
}
- .buttonStyle(.plain)
- .padding()
+
+ return !jsonFiles.isEmpty
}
}
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))
+ }
}
}
}
}
- @ViewBuilder
- var rightPane: some View {
- IfLetStore(store.scope(
- state: \.editCustomCommand,
- action: CustomCommandFeature.Action.editCustomCommand
- )) { store in
- EditCustomCommandView(store: store)
- } else: {
- CustomCommandTypeDescription(text: """
- # Send Message
-
- This command sends a message to the active chat tab. You can provide additional context through the "Extra System Prompt" as well.
-
- # Prompt to Code
-
- 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.
-
- # Custom Chat
-
- This command will overwrite the system prompt to let the bot behave differently.
-
- # Single Round Dialog
-
- 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()
+ }
+ }
}
}
}
@@ -223,7 +275,9 @@ struct CustomCommandView_Preview: PreviewProvider {
extraSystemPrompt: nil,
prompt: "Hello",
useExtraSystemPrompt: false
- )
+ ),
+ ignoreExistingAttachments: false,
+ attachments: []
),
.init(
commandId: "2",
@@ -233,7 +287,9 @@ struct CustomCommandView_Preview: PreviewProvider {
prompt: "Refactor",
continuousMode: false,
generateDescription: true
- )
+ ),
+ ignoreExistingAttachments: false,
+ attachments: []
),
], "CustomCommandView_Preview"))
@@ -247,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
)
@@ -267,7 +325,9 @@ struct CustomCommandView_NoEditing_Preview: PreviewProvider {
extraSystemPrompt: nil,
prompt: "Hello",
useExtraSystemPrompt: false
- )
+ ),
+ ignoreExistingAttachments: false,
+ attachments: []
),
.init(
commandId: "2",
@@ -277,7 +337,9 @@ struct CustomCommandView_NoEditing_Preview: PreviewProvider {
prompt: "Refactor",
continuousMode: false,
generateDescription: true
- )
+ ),
+ ignoreExistingAttachments: false,
+ attachments: []
),
], "CustomCommandView_Preview"))
@@ -286,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 b94d8ae1..527d5b7b 100644
--- a/Core/Sources/HostApp/DebugView.swift
+++ b/Core/Sources/HostApp/DebugView.swift
@@ -16,11 +16,27 @@ final class DebugSettings: ObservableObject {
var disableGitHubCopilotSettingsAutoRefreshOnAppear
@AppStorage(\.useUserDefaultsBaseAPIKeychain) var useUserDefaultsBaseAPIKeychain
@AppStorage(\.disableEnhancedWorkspace) var disableEnhancedWorkspace
+ @AppStorage(\.disableGitIgnoreCheck) var disableGitIgnoreCheck
+ @AppStorage(\.disableFileContentManipulationByCheatsheet)
+ var disableFileContentManipulationByCheatsheet
+ @AppStorage(\.restartXcodeInspectorIfAccessibilityAPIIsMalfunctioning)
+ var restartXcodeInspectorIfAccessibilityAPIIsMalfunctioning
+ @AppStorage(\.restartXcodeInspectorIfAccessibilityAPIIsMalfunctioningNoTimer)
+ var restartXcodeInspectorIfAccessibilityAPIIsMalfunctioningNoTimer
+ @AppStorage(\.toastForTheReasonWhyXcodeInspectorNeedsToBeRestarted)
+ var toastForTheReasonWhyXcodeInspectorNeedsToBeRestarted
+ @AppStorage(\.observeToAXNotificationWithDefaultMode)
+ 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 {
@@ -41,7 +57,7 @@ struct DebugSettingsView: View {
Text("Use custom scroll view workaround for smooth scrolling")
}
Toggle(isOn: $settings.triggerActionWithAccessibilityAPI) {
- Text("Trigger command with AccessibilityAPI")
+ Text("Trigger command with Accessibility API")
}
Group {
Toggle(isOn: $settings.alwaysAcceptSuggestionWithAccessibilityAPI) {
@@ -59,23 +75,81 @@ struct DebugSettingsView: View {
Toggle(isOn: $settings.useUserDefaultsBaseAPIKeychain) {
Text("Store API keys in UserDefaults")
}
-
+
Toggle(isOn: $settings.disableEnhancedWorkspace) {
- Text("Disable Enhanced Workspace")
+ Text("Disable enhanced workspace")
}
-
- Button("Reset Migration Version to 0") {
+
+ Toggle(isOn: $settings.disableGitIgnoreCheck) {
+ Text("Disable git ignore check")
+ }
+
+ Toggle(isOn: $settings.disableFileContentManipulationByCheatsheet) {
+ Text("Disable file content manipulation by cheatsheet")
+ }
+
+ Group {
+ Toggle(
+ isOn: $settings
+ .restartXcodeInspectorIfAccessibilityAPIIsMalfunctioning
+ ) {
+ Text(
+ "Re-activate Xcode Inspector when Accessibility API malfunctioning detected"
+ )
+ }
+
+ Toggle(
+ isOn: $settings
+ .restartXcodeInspectorIfAccessibilityAPIIsMalfunctioningNoTimer
+ ) {
+ Text("Trigger malfunctioning detection only with events")
+ }
+
+ Toggle(
+ isOn: $settings
+ .toastForTheReasonWhyXcodeInspectorNeedsToBeRestarted
+ ) {
+ Text("Toast for the reason of re-activation of Xcode Inspector")
+ }
+ }
+
+ Button("Reset migration version to 0") {
UserDefaults.shared.set(nil, forKey: "OldMigrationVersion")
}
-
+
Button("Reset 0.23.0 migration") {
UserDefaults.shared.set("239", forKey: "OldMigrationVersion")
UserDefaults.shared.set(nil, forKey: "MigrateTo240Finished")
UserDefaults.shared.set(nil, forKey: "ChatModels")
UserDefaults.shared.set(nil, forKey: "EmbeddingModels")
}
+
+ Group {
+ Toggle(
+ isOn: $settings.observeToAXNotificationWithDefaultMode
+ ) {
+ Text("Observe to AXNotification with default mode")
+ }
+ }
+
+ Toggle(
+ isOn: $settings.useCloudflareDomainNameForLicenseCheck
+ ) {
+ 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)
.padding()
}
}
@@ -87,4 +161,3 @@ struct DebugSettingsView_Preview: PreviewProvider {
}
}
-
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