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
- }
-}
-
-extension [Package.Dependency] {
- var pro: [Package.Dependency] {
- if isProIncluded {
- // include the pro package
- return self + [.package(path: "../Pro/Pro")]
- }
- return self
- }
-}
-
-let isProIncluded: Bool = {
- func isProIncluded(file: StaticString = #file) -> Bool {
- let filePath = "\(file)"
- let fileURL = URL(fileURLWithPath: filePath)
- let rootURL = fileURL
- .deletingLastPathComponent()
- .deletingLastPathComponent()
- let confURL = rootURL.appendingPathComponent("PLUS")
- if !FileManager.default.fileExists(atPath: confURL.path) {
- return false
- }
- do {
- if let content = try String(
- data: Data(contentsOf: confURL),
- encoding: .utf8
- ) {
- if content.hasPrefix("YES") {
- return true
- }
- }
- return false
- } catch {
- return false
- }
- }
-
- return isProIncluded()
-}()
-
// MARK: - Package
let package = Package(
name: "Core",
- platforms: [.macOS(.v12)],
+ platforms: [.macOS(.v13)],
products: [
.library(
name: "Service",
targets: [
"Service",
- "SuggestionInjector",
"FileChangeChecker",
"LaunchAgentManager",
"UpdateChecker",
@@ -89,18 +37,22 @@ let package = Package(
],
dependencies: [
.package(path: "../Tool"),
- .package(url: "https://github.com/apple/swift-async-algorithms", from: "0.1.0"),
- .package(url: "https://github.com/gonzalezreal/swift-markdown-ui", from: "2.1.0"),
- .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.0.0"),
+ .package(path: "../ChatPlugins"),
+ .package(path: "../OverlayWindow"),
+ .package(url: "https://github.com/apple/swift-async-algorithms", from: "1.0.0"),
+ .package(url: "https://github.com/gonzalezreal/swift-markdown-ui", from: "2.4.1"),
+ .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.6.4"),
.package(url: "https://github.com/pointfreeco/swift-parsing", from: "0.12.1"),
- .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "0.5.1"),
+ .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.0.0"),
.package(
url: "https://github.com/pointfreeco/swift-composable-architecture",
- from: "0.55.0"
+ exact: "1.16.1"
),
// quick hack to support custom UserDefaults
// https://github.com/sindresorhus/KeyboardShortcuts
.package(url: "https://github.com/intitni/KeyboardShortcuts", branch: "main"),
+ .package(url: "https://github.com/intitni/CGEventOverride", from: "1.2.1"),
+ .package(url: "https://github.com/intitni/Highlightr", branch: "master"),
].pro,
targets: [
// MARK: - Main
@@ -109,35 +61,45 @@ let package = Package(
name: "Client",
dependencies: [
.product(name: "XPCShared", package: "Tool"),
- .product(name: "SuggestionService", package: "Tool"),
- .product(name: "SuggestionModel", package: "Tool"),
+ .product(name: "SuggestionProvider", package: "Tool"),
+ .product(name: "SuggestionBasic", package: "Tool"),
.product(name: "Logger", package: "Tool"),
.product(name: "Preferences", package: "Tool"),
- ]
+ ].proCore([
+ "LicenseManagement",
+ ])
),
.target(
name: "Service",
dependencies: [
"SuggestionWidget",
+ "SuggestionService",
"ChatService",
"PromptToCodeService",
"ServiceUpdateMigration",
"ChatGPTChatTab",
+ "PlusFeatureFlag",
+ "KeyBindingManager",
+ "XcodeThemeController",
.product(name: "XPCShared", package: "Tool"),
- .product(name: "SuggestionService", package: "Tool"),
+ .product(name: "SuggestionProvider", package: "Tool"),
.product(name: "Workspace", package: "Tool"),
+ .product(name: "WorkspaceSuggestionService", package: "Tool"),
.product(name: "UserDefaultsObserver", package: "Tool"),
.product(name: "AppMonitoring", package: "Tool"),
- .product(name: "Environment", package: "Tool"),
- .product(name: "SuggestionModel", package: "Tool"),
+ .product(name: "SuggestionBasic", package: "Tool"),
+ .product(name: "PromptToCode", package: "Tool"),
.product(name: "ChatTab", package: "Tool"),
.product(name: "Logger", package: "Tool"),
.product(name: "OpenAIService", package: "Tool"),
.product(name: "Preferences", package: "Tool"),
+ .product(name: "CommandHandler", package: "Tool"),
+ .product(name: "OverlayWindow", package: "OverlayWindow"),
.product(name: "AsyncAlgorithms", package: "swift-async-algorithms"),
.product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "KeyboardShortcuts", package: "KeyboardShortcuts"),
+ .product(name: "CustomCommandTemplateProcessor", package: "Tool"),
].pro([
"ProService",
])
@@ -147,11 +109,9 @@ let package = Package(
dependencies: [
"Service",
"Client",
- "SuggestionInjector",
.product(name: "XPCShared", package: "Tool"),
- .product(name: "SuggestionService", package: "Tool"),
- .product(name: "SuggestionModel", package: "Tool"),
- .product(name: "Environment", package: "Tool"),
+ .product(name: "SuggestionProvider", package: "Tool"),
+ .product(name: "SuggestionBasic", package: "Tool"),
.product(name: "Preferences", package: "Tool"),
]
),
@@ -164,10 +124,11 @@ let package = Package(
"Client",
"LaunchAgentManager",
"PlusFeatureFlag",
- .product(name: "SuggestionService", package: "Tool"),
+ .product(name: "SuggestionProvider", package: "Tool"),
.product(name: "Toast", package: "Tool"),
.product(name: "SharedUIComponents", package: "Tool"),
- .product(name: "SuggestionModel", package: "Tool"),
+ .product(name: "SuggestionBasic", package: "Tool"),
+ .product(name: "WebSearchService", package: "Tool"),
.product(name: "MarkdownUI", package: "swift-markdown-ui"),
.product(name: "OpenAIService", package: "Tool"),
.product(name: "Preferences", package: "Tool"),
@@ -181,12 +142,15 @@ let package = Package(
// MARK: - Suggestion Service
.target(
- name: "SuggestionInjector",
- dependencies: [.product(name: "SuggestionModel", package: "Tool")]
- ),
- .testTarget(
- name: "SuggestionInjectorTests",
- dependencies: ["SuggestionInjector"]
+ name: "SuggestionService",
+ dependencies: [
+ .product(name: "UserDefaultsObserver", package: "Tool"),
+ .product(name: "Preferences", package: "Tool"),
+ .product(name: "SuggestionBasic", package: "Tool"),
+ .product(name: "SuggestionProvider", package: "Tool"),
+ ].pro([
+ "ProExtension",
+ ])
),
// MARK: - Prompt To Code
@@ -194,13 +158,15 @@ let package = Package(
.target(
name: "PromptToCodeService",
dependencies: [
+ .product(name: "PromptToCode", package: "Tool"),
.product(name: "FocusedCodeFinder", package: "Tool"),
- .product(name: "SuggestionModel", package: "Tool"),
- .product(name: "Environment", package: "Tool"),
+ .product(name: "SuggestionBasic", package: "Tool"),
.product(name: "OpenAIService", package: "Tool"),
.product(name: "AppMonitoring", package: "Tool"),
.product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
- ]
+ ].pro([
+ "ProService",
+ ])
),
.testTarget(name: "PromptToCodeServiceTests", dependencies: ["PromptToCodeService"]),
@@ -209,32 +175,29 @@ let package = Package(
.target(
name: "ChatService",
dependencies: [
- "ChatPlugin",
-
- // plugins
- "MathChatPlugin",
- "SearchChatPlugin",
- "ShortcutChatPlugin",
+ "LegacyChatPlugin",
// context collectors
"WebChatContextCollector",
"SystemInfoChatContextCollector",
.product(name: "ChatContextCollector", package: "Tool"),
+ .product(name: "PromptToCode", package: "Tool"),
.product(name: "AppMonitoring", package: "Tool"),
- .product(name: "Environment", package: "Tool"),
- .product(name: "Parsing", package: "swift-parsing"),
.product(name: "OpenAIService", package: "Tool"),
.product(name: "Preferences", package: "Tool"),
+ .product(name: "CustomCommandTemplateProcessor", package: "Tool"),
+ .product(name: "ChatPlugins", package: "ChatPlugins"),
+ .product(name: "Parsing", package: "swift-parsing"),
].pro([
"ProService",
])
),
.testTarget(name: "ChatServiceTests", dependencies: ["ChatService"]),
.target(
- name: "ChatPlugin",
+ name: "LegacyChatPlugin",
dependencies: [
- .product(name: "Environment", package: "Tool"),
+ .product(name: "AppMonitoring", package: "Tool"),
.product(name: "OpenAIService", package: "Tool"),
.product(name: "Terminal", package: "Tool"),
]
@@ -248,6 +211,7 @@ let package = Package(
.product(name: "OpenAIService", package: "Tool"),
.product(name: "Logger", package: "Tool"),
.product(name: "ChatTab", package: "Tool"),
+ .product(name: "Terminal", package: "Tool"),
.product(name: "MarkdownUI", package: "swift-markdown-ui"),
.product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
]
@@ -260,12 +224,15 @@ let package = Package(
dependencies: [
"PromptToCodeService",
"ChatGPTChatTab",
+ .product(name: "PromptToCode", package: "Tool"),
+ .product(name: "Toast", package: "Tool"),
.product(name: "UserDefaultsObserver", package: "Tool"),
.product(name: "SharedUIComponents", package: "Tool"),
.product(name: "AppMonitoring", package: "Tool"),
- .product(name: "Environment", package: "Tool"),
.product(name: "ChatTab", package: "Tool"),
.product(name: "Logger", package: "Tool"),
+ .product(name: "CustomAsyncAlgorithms", package: "Tool"),
+ .product(name: "CodeDiff", package: "Tool"),
.product(name: "AsyncAlgorithms", package: "swift-async-algorithms"),
.product(name: "MarkdownUI", package: "swift-markdown-ui"),
.product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
@@ -281,13 +248,14 @@ let package = Package(
name: "UpdateChecker",
dependencies: [
"Sparkle",
+ .product(name: "Preferences", package: "Tool"),
.product(name: "Logger", package: "Tool"),
]
),
.target(
name: "ServiceUpdateMigration",
dependencies: [
- .product(name: "SuggestionService", package: "Tool"),
+ .product(name: "SuggestionProvider", package: "Tool"),
.product(name: "Preferences", package: "Tool"),
.product(name: "Keychain", package: "Tool"),
]
@@ -306,61 +274,100 @@ let package = Package(
])
),
- // MARK: - Chat Plugins
+ // MAKR: - Chat Context Collector
.target(
- name: "MathChatPlugin",
+ name: "WebChatContextCollector",
dependencies: [
- "ChatPlugin",
- .product(name: "OpenAIService", package: "Tool"),
+ .product(name: "ChatContextCollector", package: "Tool"),
.product(name: "LangChain", package: "Tool"),
- ],
- path: "Sources/ChatPlugins/MathChatPlugin"
- ),
-
- .target(
- name: "SearchChatPlugin",
- dependencies: [
- "ChatPlugin",
.product(name: "OpenAIService", package: "Tool"),
- .product(name: "LangChain", package: "Tool"),
.product(name: "ExternalServices", package: "Tool"),
+ .product(name: "Preferences", package: "Tool"),
],
- path: "Sources/ChatPlugins/SearchChatPlugin"
+ path: "Sources/ChatContextCollectors/WebChatContextCollector"
),
.target(
- name: "ShortcutChatPlugin",
+ name: "SystemInfoChatContextCollector",
dependencies: [
- "ChatPlugin",
- .product(name: "Parsing", package: "swift-parsing"),
- .product(name: "Terminal", package: "Tool"),
+ .product(name: "ChatContextCollector", package: "Tool"),
+ .product(name: "OpenAIService", package: "Tool"),
],
- path: "Sources/ChatPlugins/ShortcutChatPlugin"
+ path: "Sources/ChatContextCollectors/SystemInfoChatContextCollector"
),
- // MAKR: - Chat Context Collector
+ // MARK: Key Binding
.target(
- name: "WebChatContextCollector",
+ name: "KeyBindingManager",
dependencies: [
- .product(name: "ChatContextCollector", package: "Tool"),
- .product(name: "LangChain", package: "Tool"),
- .product(name: "OpenAIService", package: "Tool"),
- .product(name: "ExternalServices", package: "Tool"),
+ .product(name: "CommandHandler", package: "Tool"),
+ .product(name: "Workspace", package: "Tool"),
.product(name: "Preferences", package: "Tool"),
- ],
- path: "Sources/ChatContextCollectors/WebChatContextCollector"
+ .product(name: "Logger", package: "Tool"),
+ .product(name: "AppMonitoring", package: "Tool"),
+ .product(name: "UserDefaultsObserver", package: "Tool"),
+ .product(name: "CGEventOverride", package: "CGEventOverride"),
+ .product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
+ ]
+ ),
+ .testTarget(
+ name: "KeyBindingManagerTests",
+ dependencies: ["KeyBindingManager"]
),
+ // MARK: Theming
+
.target(
- name: "SystemInfoChatContextCollector",
+ name: "XcodeThemeController",
dependencies: [
- .product(name: "ChatContextCollector", package: "Tool"),
- .product(name: "OpenAIService", package: "Tool"),
- ],
- path: "Sources/ChatContextCollectors/SystemInfoChatContextCollector"
+ .product(name: "Preferences", package: "Tool"),
+ .product(name: "AppMonitoring", package: "Tool"),
+ .product(name: "Highlightr", package: "Highlightr"),
+ ]
),
]
)
+extension [Target.Dependency] {
+ func pro(_ targetNames: [String]) -> [Target.Dependency] {
+ if isProIncluded {
+ return self + targetNames.map { Target.Dependency.product(name: $0, package: "Pro") }
+ }
+ return self
+ }
+
+ func proCore(_ targetNames: [String]) -> [Target.Dependency] {
+ if isProIncluded {
+ return self + targetNames
+ .map { Target.Dependency.product(name: $0, package: "ProCore") }
+ }
+ return self
+ }
+}
+
+extension [Package.Dependency] {
+ var pro: [Package.Dependency] {
+ if isProIncluded {
+ return self + [.package(path: "../../Pro"), .package(path: "../../Pro/ProCore")]
+ }
+ return self
+ }
+}
+
+var isProIncluded: Bool {
+ func isProIncluded(file: StaticString = #file) -> Bool {
+ let filePath = "\(file)"
+ let fileURL = URL(fileURLWithPath: filePath)
+ let rootURL = fileURL
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+ let confURL = rootURL.appendingPathComponent("PLUS")
+ return FileManager.default.fileExists(atPath: confURL.path)
+ }
+
+ return isProIncluded()
+}
+
diff --git a/Core/Sources/ChatContextCollectors/SystemInfoChatContextCollector/SystemInfoChatContextCollector.swift b/Core/Sources/ChatContextCollectors/SystemInfoChatContextCollector/SystemInfoChatContextCollector.swift
index d5563b51..9686ca85 100644
--- a/Core/Sources/ChatContextCollectors/SystemInfoChatContextCollector/SystemInfoChatContextCollector.swift
+++ b/Core/Sources/ChatContextCollectors/SystemInfoChatContextCollector/SystemInfoChatContextCollector.swift
@@ -13,7 +13,7 @@ public final class SystemInfoChatContextCollector: ChatContextCollector {
public func generateContext(
history: [ChatMessage],
- scopes: Set,
+ scopes: Set,
content: String,
configuration: ChatGPTConfiguration
) -> ChatContext {
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 81d1b9fc..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 .empty }
+ 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),
@@ -32,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
index 72d00503..28443876 100644
--- a/Core/Sources/ChatGPTChatTab/Chat.swift
+++ b/Core/Sources/ChatGPTChatTab/Chat.swift
@@ -1,43 +1,90 @@
+import AppKit
+import ChatBasic
import ChatService
import ComposableArchitecture
import Foundation
+import MarkdownUI
import OpenAIService
import Preferences
+import Terminal
-public struct ChatMessage: Equatable {
- public enum Role {
+public struct DisplayedChatMessage: Equatable {
+ public enum Role: Equatable {
case user
case assistant
- case function
+ 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) {
+ 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
}
}
-struct Chat: ReducerProtocol {
+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"
- @BindingState var typedMessage = ""
- var history: [ChatMessage] = []
- @BindingState var isReceivingMessage = false
+ 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
@@ -45,17 +92,23 @@ struct Chat: ReducerProtocol {
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)
}
@@ -68,12 +121,14 @@ struct Chat: ReducerProtocol {
case observeIsReceivingMessageChange(UUID)
case observeSystemPromptChange(UUID)
case observeExtraSystemPromptChange(UUID)
+ case observeDefaultScopesChange(UUID)
+ case sendMessage(UUID)
}
- var body: some ReducerProtocol {
+ var body: some ReducerOf {
BindingReducer()
- Scope(state: \.chatMenu, action: /Action.chatMenu) {
+ Scope(state: \.chatMenu, action: \.chatMenu) {
ChatMenu(service: service)
}
@@ -81,29 +136,44 @@ struct Chat: ReducerProtocol {
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 .run { _ in
- await service.stopReceivingMessage()
- }
+ return .merge(
+ .run { _ in
+ await service.stopReceivingMessage()
+ },
+ .cancel(id: CancelID.sendMessage(id))
+ )
case .clearButtonTap:
return .run { _ in
@@ -125,12 +195,47 @@ struct Chat: ReducerProtocol {
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:
@@ -143,9 +248,13 @@ struct Chat: ReducerProtocol {
cancellable.cancel()
}
}
- for await _ in stream {
+ let debouncedHistoryChange = TimedDebounceFunction(duration: 0.2) {
await send(.historyChanged)
}
+
+ for await _ in stream {
+ await debouncedHistoryChange()
+ }
}.cancellable(id: CancelID.observeHistoryChange(id), cancelInFlight: true)
case .observeIsReceivingMessageChange:
@@ -198,9 +307,26 @@ struct Chat: ReducerProtocol {
}
}.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.map { message in
- .init(
+ state.history = service.chatHistory.flatMap { message in
+ var all = [DisplayedChatMessage]()
+ all.append(.init(
id: message.id,
role: {
switch message.role {
@@ -213,11 +339,22 @@ struct Chat: ReducerProtocol {
return .assistant
}
return .ignored
- case .function: return .function
}
}(),
- text: message.summary ?? message.content ?? ""
- )
+ 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 = {
@@ -240,6 +377,9 @@ struct Chat: ReducerProtocol {
case .isReceivingMessageChanged:
state.isReceivingMessage = service.isReceivingMessage
+ if service.isReceivingMessage {
+ state.isPinnedToBottom = true
+ }
return .none
case .systemPromptChanged:
@@ -250,6 +390,10 @@ struct Chat: ReducerProtocol {
state.chatMenu.extraSystemPrompt = service.extraSystemPrompt
return .none
+ case .defaultScopesChanged:
+ state.chatMenu.defaultScopes = service.defaultScopes
+ return .none
+
case .binding:
return .none
@@ -260,28 +404,39 @@ struct Chat: ReducerProtocol {
}
}
-struct ChatMenu: ReducerProtocol {
+@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 ReducerProtocol {
+ 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
@@ -304,8 +459,97 @@ struct ChatMenu: ReducerProtocol {
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 bff64f4e..9114a5dd 100644
--- a/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift
+++ b/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift
@@ -1,4 +1,5 @@
import AppKit
+import ChatService
import ComposableArchitecture
import SharedUIComponents
import SwiftUI
@@ -7,8 +8,8 @@ struct ChatTabItemView: View {
let chat: StoreOf
var body: some View {
- WithViewStore(chat, observe: \.title) { viewStore in
- Text(viewStore.state)
+ WithPerceptionTracking {
+ Text(chat.title)
}
}
}
@@ -21,45 +22,44 @@ struct ChatContextMenu: View {
@AppStorage(\.chatGPTTemperature) var defaultTemperature
var body: some View {
- currentSystemPrompt
- .onAppear { store.send(.appear) }
- currentExtraSystemPrompt
- resetPrompt
+ WithPerceptionTracking {
+ currentSystemPrompt
+ .onAppear { store.send(.appear) }
+ currentExtraSystemPrompt
+ resetPrompt
- Divider()
+ Divider()
- chatModel
- temperature
+ chatModel
+ temperature
+ defaultScopes
- Divider()
+ Divider()
- customCommandMenu
+ customCommandMenu
+ }
}
@ViewBuilder
var currentSystemPrompt: some View {
Text("System Prompt:")
- WithViewStore(store, observe: \.systemPrompt) { viewStore in
- Text({
- var text = viewStore.state
- if text.isEmpty { text = "N/A" }
- if text.count > 30 { text = String(text.prefix(30)) + "..." }
- return text
- }() as String)
- }
+ Text({
+ var text = store.systemPrompt
+ if text.isEmpty { text = "N/A" }
+ if text.count > 30 { text = String(text.prefix(30)) + "..." }
+ return text
+ }() as String)
}
@ViewBuilder
var currentExtraSystemPrompt: some View {
Text("Extra Prompt:")
- WithViewStore(store, observe: \.extraSystemPrompt) { viewStore in
- Text({
- var text = viewStore.state
- if text.isEmpty { text = "N/A" }
- if text.count > 30 { text = String(text.prefix(30)) + "..." }
- return text
- }() as String)
- }
+ Text({
+ var text = store.extraSystemPrompt
+ if text.isEmpty { text = "N/A" }
+ if text.count > 30 { text = String(text.prefix(30)) + "..." }
+ return text
+ }() as String)
}
var resetPrompt: some View {
@@ -70,47 +70,52 @@ struct ChatContextMenu: View {
@ViewBuilder
var chatModel: some View {
+ let allModels = chatModels + [.init(
+ id: "com.github.copilot",
+ name: "GitHub Copilot Language Server",
+ format: .openAI,
+ info: .init()
+ )]
+
Menu("Chat Model") {
- WithViewStore(store, observe: \.chatModelIdOverride) { viewStore in
- Button(action: {
- viewStore.send(.chatModelIdOverrideSelected(nil))
- }) {
- HStack {
- if let defaultModel = chatModels
- .first(where: { $0.id == defaultChatModelId })
- {
- Text("Default (\(defaultModel.name))")
- if viewStore.state == nil {
- Image(systemName: "checkmark")
- }
- } else {
- Text("No Model Available")
+ Button(action: {
+ store.send(.chatModelIdOverrideSelected(nil))
+ }) {
+ HStack {
+ if let defaultModel = allModels
+ .first(where: { $0.id == defaultChatModelId })
+ {
+ Text("Default (\(defaultModel.name))")
+ if store.chatModelIdOverride == nil {
+ Image(systemName: "checkmark")
}
+ } else {
+ Text("No Model Available")
}
}
+ }
- if let id = viewStore.state, !chatModels.map(\.id).contains(id) {
- Button(action: {
- viewStore.send(.chatModelIdOverrideSelected(nil))
- }) {
- HStack {
- Text("Default (Selected Model Not Found)")
- Image(systemName: "checkmark")
- }
+ if let id = store.chatModelIdOverride, !allModels.map(\.id).contains(id) {
+ Button(action: {
+ store.send(.chatModelIdOverrideSelected(nil))
+ }) {
+ HStack {
+ Text("Default (Selected Model Not Found)")
+ Image(systemName: "checkmark")
}
}
+ }
- Divider()
-
- ForEach(chatModels, id: \.id) { model in
- Button(action: {
- viewStore.send(.chatModelIdOverrideSelected(model.id))
- }) {
- HStack {
- Text(model.name)
- if model.id == viewStore.state {
- Image(systemName: "checkmark")
- }
+ Divider()
+
+ ForEach(allModels, id: \.id) { model in
+ Button(action: {
+ store.send(.chatModelIdOverrideSelected(model.id))
+ }) {
+ HStack {
+ Text(model.name)
+ if model.id == store.chatModelIdOverride {
+ Image(systemName: "checkmark")
}
}
}
@@ -121,31 +126,55 @@ struct ChatContextMenu: View {
@ViewBuilder
var temperature: some View {
Menu("Temperature") {
- WithViewStore(store, observe: \.temperatureOverride) { viewStore in
+ Button(action: {
+ store.send(.temperatureOverrideSelected(nil))
+ }) {
+ HStack {
+ Text(
+ "Default (\(defaultTemperature.formatted(.number.precision(.fractionLength(1)))))"
+ )
+ if store.temperatureOverride == nil {
+ Image(systemName: "checkmark")
+ }
+ }
+ }
+
+ Divider()
+
+ ForEach(Array(stride(from: 0.0, through: 2.0, by: 0.1)), id: \.self) { value in
Button(action: {
- viewStore.send(.temperatureOverrideSelected(nil))
+ store.send(.temperatureOverrideSelected(value))
}) {
HStack {
- Text(
- "Default (\(defaultTemperature.formatted(.number.precision(.fractionLength(1)))))"
- )
- if viewStore.state == nil {
+ Text("\(value.formatted(.number.precision(.fractionLength(1))))")
+ if value == store.temperatureOverride {
Image(systemName: "checkmark")
}
}
}
+ }
+ }
+ }
- Divider()
-
- ForEach(Array(stride(from: 0.0, through: 2.0, by: 0.1)), id: \.self) { value in
- Button(action: {
- viewStore.send(.temperatureOverrideSelected(value))
- }) {
- HStack {
- Text("\(value.formatted(.number.precision(.fractionLength(1))))")
- if value == viewStore.state {
- Image(systemName: "checkmark")
- }
+ @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")
}
}
}
diff --git a/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift b/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift
index 17babd6d..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
@@ -13,14 +16,16 @@ public class ChatGPTChatTab: ChatTab {
public let service: ChatService
let chat: StoreOf
- let viewStore: ViewStoreOf
private var cancellable = Set()
+ private var observer = NSObject()
+ private let updateContentDebounce = DebounceRunner(duration: 0.5)
struct RestorableState: Codable {
var history: [OpenAIService.ChatMessage]
var configuration: OverridingChatGPTConfiguration.Overriding
var systemPrompt: String
var extraSystemPrompt: String
+ var defaultScopes: Set?
}
struct Builder: ChatTabBuilder {
@@ -47,8 +52,8 @@ public class ChatGPTChatTab: ChatTab {
}
public func buildIcon() -> any View {
- WithViewStore(chat, observe: \.isReceivingMessage) { viewStore in
- if viewStore.state {
+ WithPerceptionTracking {
+ if self.chat.isReceivingMessage {
Image(systemName: "ellipsis.message")
} else {
Image(systemName: "message")
@@ -57,7 +62,7 @@ public class ChatGPTChatTab: ChatTab {
}
public func buildMenu() -> any View {
- ChatContextMenu(store: chat.scope(state: \.chatMenu, action: Chat.Action.chatMenu))
+ ChatContextMenu(store: chat.scope(state: \.chatMenu, action: \.chatMenu))
}
public func restorableState() async -> Data {
@@ -65,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 {
@@ -95,44 +102,87 @@ public class ChatGPTChatTab: ChatTab {
return nil
}
- return [Builder(title: "New Chat", customCommand: nil)] + customCommands
+ return [Builder(title: "Legacy Chat", customCommand: nil)] + customCommands
+ }
+
+ public static func defaultBuilder() -> ChatTabBuilder {
+ Builder(title: "Legacy Chat", customCommand: nil)
}
@MainActor
public init(service: ChatService = .init(), store: StoreOf) {
self.service = service
- chat = .init(initialState: .init(), reducer: Chat(service: service))
- viewStore = .init(chat)
+ chat = .init(initialState: .init(), reducer: { Chat(service: service) })
super.init(store: store)
}
public func start() {
- chatTabViewStore.send(.updateTitle("Chat"))
+ observer = .init()
+ cancellable = []
- service.$systemPrompt.removeDuplicates().sink { _ in
+ chatTabStore.send(.updateTitle("Chat"))
+
+ service.$systemPrompt.removeDuplicates().sink { [weak self] _ in
Task { @MainActor [weak self] in
- self?.chatTabViewStore.send(.tabContentUpdated)
+ self?.chatTabStore.send(.tabContentUpdated)
}
}.store(in: &cancellable)
- service.$extraSystemPrompt.removeDuplicates().sink { _ in
+ service.$extraSystemPrompt.removeDuplicates().sink { [weak self] _ in
Task { @MainActor [weak self] in
- self?.chatTabViewStore.send(.tabContentUpdated)
+ self?.chatTabStore.send(.tabContentUpdated)
}
}.store(in: &cancellable)
- viewStore.publisher.map(\.title).removeDuplicates().sink { [weak self] title in
- Task { @MainActor [weak self] in
- self?.chatTabViewStore.send(.updateTitle(title))
+ Task { @MainActor in
+ var lastTrigger = -1
+ observer.observe { [weak self] in
+ guard let self else { return }
+ let trigger = chatTabStore.focusTrigger
+ guard lastTrigger != trigger else { return }
+ lastTrigger = trigger
+ Task { @MainActor [weak self] in
+ self?.chat.send(.focusOnTextField)
+ }
}
- }.store(in: &cancellable)
+ }
- viewStore.publisher.removeDuplicates()
- .sink { [weak self] _ in
+ 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?.chatTabViewStore.send(.tabContentUpdated)
+ self?.chatTabStore.send(.updateTitle(title))
}
- }.store(in: &cancellable)
+ }
+ }
+
+ Task { @MainActor in
+ observer.observe { [weak self] in
+ guard let self else { return }
+ _ = chat.history
+ _ = chat.title
+ _ = chat.isReceivingMessage
+ Task {
+ await self.updateContentDebounce.debounce { @MainActor [weak self] in
+ self?.chatTabStore.send(.tabContentUpdated)
+ }
+ }
+ }
+ }
+ }
+
+ public func handleCustomCommand(_ customCommand: CustomCommand) -> Bool {
+ Task {
+ if service.isReceivingMessage {
+ await service.stopReceivingMessage()
+ }
+ try? await service.handleCustomCommand(customCommand)
+ }
+ return true
}
}
diff --git a/Core/Sources/ChatGPTChatTab/ChatPanel.swift b/Core/Sources/ChatGPTChatTab/ChatPanel.swift
index 2b28793c..9210a05d 100644
--- a/Core/Sources/ChatGPTChatTab/ChatPanel.swift
+++ b/Core/Sources/ChatGPTChatTab/ChatPanel.swift
@@ -1,4 +1,5 @@
import AppKit
+import Combine
import ComposableArchitecture
import MarkdownUI
import OpenAIService
@@ -17,7 +18,7 @@ public struct ChatPanel: View {
Divider()
ChatPanelInputArea(chat: chat)
}
- .background(.regularMaterial)
+ .background(Color(nsColor: .windowBackgroundColor))
.onAppear { chat.send(.appear) }
}
}
@@ -40,402 +41,333 @@ private struct ListHeightPreferenceKey: PreferenceKey {
struct ChatPanelMessages: View {
let chat: StoreOf
- @State var pinnedToBottom = true
+ @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 isInitialLoad = true
+ @State var didScrollToBottomOnAppearOnce = false
+ @State var isBottomHidden = true
+ @Environment(\.isEnabled) var isEnabled
var body: some View {
- ScrollViewReader { proxy in
- GeometryReader { listGeo in
- List {
- Group {
- Spacer(minLength: 12)
-
- Instruction()
-
- ChatHistory(chat: chat)
- .listItemTint(.clear)
-
- WithViewStore(chat, observe: \.isReceivingMessage) { viewStore in
- if viewStore.state {
- Spacer(minLength: 12)
- }
- }
-
- Spacer(minLength: 12)
- .onAppear {
- withAnimation {
- proxy.scrollTo(bottomID, anchor: .bottom)
+ 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
+ }
}
- }
- .id(bottomID)
- .background(GeometryReader { geo in
- let offset = geo.frame(in: .named(scrollSpace)).minY
- Color.clear
- .preference(
+ .onDisappear {
+ isBottomHidden = true
+ }
+ .background(GeometryReader { geo in
+ let offset = geo.frame(in: .named(scrollSpace)).minY
+ Color.clear.preference(
key: ScrollViewOffsetPreferenceKey.self,
value: offset
)
- })
- .preference(
- key: ListHeightPreferenceKey.self,
- value: listGeo.size.height
- )
+ })
+ }
+ .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.listRowSeparator(.hidden).listSectionSeparator(.hidden)
+ view.scrollContentBackground(.hidden)
} else {
view
}
}
- }
- .listStyle(.plain)
- .coordinateSpace(name: scrollSpace)
- .onPreferenceChange(ListHeightPreferenceKey.self) { value in
- listHeight = value
- updatePinningState()
- }
- .onPreferenceChange(ScrollViewOffsetPreferenceKey.self) { value in
- scrollOffset = value
- updatePinningState()
- }
- .overlay(alignment: .bottom) {
- WithViewStore(chat, observe: \.isReceivingMessage) { viewStore in
+ .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)
- .padding(.bottom, 8)
- .opacity(viewStore.state ? 1 : 0)
- .disabled(!viewStore.state)
- .transformEffect(.init(translationX: 0, y: viewStore.state ? 0 : 20))
- .animation(.easeInOut(duration: 0.2), value: viewStore.state)
}
- }
- .overlay(alignment: .bottomTrailing) {
- WithViewStore(chat, observe: \.history.last) { viewStore in
- Button(action: {
- 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 {
- Circle().stroke(Color(nsColor: .separatorColor), lineWidth: 1)
- }
- .foregroundStyle(.secondary)
- .padding(4)
- }
- .keyboardShortcut(.downArrow, modifiers: [.command])
- .opacity(pinnedToBottom ? 0 : 1)
- .buttonStyle(.plain)
- .onChange(of: viewStore.state) { _ in
- if pinnedToBottom || isInitialLoad {
- if isInitialLoad {
- isInitialLoad = false
- }
- withAnimation {
- proxy.scrollTo(bottomID, anchor: .bottom)
- }
- }
+ .overlay(alignment: .bottomTrailing) {
+ scrollToBottomButton(proxy: proxy)
+ }
+ .background {
+ PinToBottomHandler(chat: chat, isBottomHidden: isBottomHidden) {
+ proxy.scrollTo(bottomID, anchor: .bottom)
}
}
+ .onAppear {
+ proxy.scrollTo(bottomID, anchor: .bottom)
+ }
+ .task {
+ proxy.scrollTo(bottomID, anchor: .bottom)
+ }
}
}
+ .onAppear {
+ trackScrollWheel()
+ }
+ .onDisappear {
+ cancellable.forEach { $0.cancel() }
+ cancellable = []
+ }
+ .onChange(of: isEnabled) { isEnabled in
+ chat.send(.setIsEnabled(isEnabled))
+ }
}
}
+ 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)
+ }
+
+ @MainActor
func updatePinningState() {
- if scrollOffset > listHeight + 24 + 100 || scrollOffset <= 0 {
- pinnedToBottom = false
- } else {
- pinnedToBottom = true
+ // where does the 32 come from?
+ withAnimation(.linear(duration: 0.1)) {
+ isScrollToBottomButtonDisplayed = scrollOffset > listHeight + 32 + 20
+ || scrollOffset <= 0
}
}
-}
-
-struct ChatHistory: View {
- let chat: StoreOf
- var body: some View {
- WithViewStore(chat, observe: \.history) { viewStore in
- ForEach(viewStore.state, id: \.id) { message in
- let text = message.text
-
- switch message.role {
- case .user:
- UserMessage(id: message.id, text: text, chat: chat)
- .listRowInsets(EdgeInsets(
- top: 0,
- leading: -8,
- bottom: 0,
- trailing: -8
- ))
- .padding(.vertical, 4)
- case .assistant:
- BotMessage(id: message.id, text: text, 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()
- }
+ @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 {
+ Circle().stroke(Color(nsColor: .separatorColor), lineWidth: 1)
+ }
+ .foregroundStyle(.secondary)
+ .padding(4)
}
+ .keyboardShortcut(.downArrow, modifiers: [.command])
+ .opacity(isScrollToBottomButtonDisplayed ? 1 : 0)
+ .buttonStyle(.plain)
}
-}
-private struct StopRespondingButton: View {
- let chat: StoreOf
+ struct ExtraSpacingInResponding: View {
+ let chat: StoreOf
- var body: some View {
- 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)
+ var body: some View {
+ WithPerceptionTracking {
+ if chat.isReceivingMessage {
+ Spacer(minLength: 12)
+ }
}
}
- .buttonStyle(.borderless)
- .frame(maxWidth: .infinity, alignment: .center)
}
-}
-private struct Instruction: View {
- @AppStorage(\.useCodeScopeByDefaultInChatContext)
- var useCodeScopeByDefaultInChatContext
-
- var body: some View {
- Group {
- Markdown(
- """
- You can use plugins to perform various tasks.
-
- | Plugin Name | Description |
- | --- | --- |
- | `/run` | Runs a command under the project root |
- | `/math` | Solves a math problem in natural language |
- | `/search` | Searches on Bing and summarizes the results |
- | `/shortcut(name)` | Runs a shortcut from the Shortcuts.app, with the previous message as input |
- | `/shortcutInput(name)` | Runs a shortcut and uses its result as a new message |
-
- To use plugins, you can prefix a message with `/pluginName`.
- """
- )
- .modifier(InstructionModifier())
-
- Markdown(
- """
- 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`.
-
- You can use shorthand to represent a scope, such as `@c`, and enable multiple scopes with `@c+web`.
- """
- )
- .modifier(InstructionModifier())
+ struct PinToBottomHandler: View {
+ let chat: StoreOf
+ let isBottomHidden: Bool
+ let scrollToBottom: () -> Void
- Markdown(
- """
- Hello, I am your AI programming assistant. I can identify issues, explain and even improve code.
+ @State var isInitialLoad = true
- \(
- useCodeScopeByDefaultInChatContext
- ? "Scope **`@code`** is enabled by default."
- : "Scope **`@file`** is enabled by default."
- )
- """
- )
- .modifier(InstructionModifier())
+ 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()
+ }
+ }
+ }
}
}
+}
- struct InstructionModifier: ViewModifier {
- @AppStorage(\.chatFontSize) var chatFontSize
+struct ChatHistory: View {
+ let chat: StoreOf
- func body(content: Content) -> some View {
- content
- .textSelection(.enabled)
- .markdownTheme(.custom(fontSize: chatFontSize))
- .opacity(0.8)
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding()
- .overlay {
- RoundedRectangle(cornerRadius: 8)
- .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
+ var body: some View {
+ WithPerceptionTracking {
+ ForEach(chat.history, id: \.id) { message in
+ WithPerceptionTracking {
+ ChatHistoryItem(chat: chat, message: message).id(message.id)
}
+ }
}
}
}
-private struct UserMessage: View {
- let id: String
- let text: String
+struct ChatHistoryItem: View {
let chat: StoreOf
- @Environment(\.colorScheme) var colorScheme
- @AppStorage(\.chatFontSize) var chatFontSize
- @AppStorage(\.chatCodeFontSize) var chatCodeFontSize
+ let message: DisplayedChatMessage
var body: some View {
- Markdown(text)
- .textSelection(.enabled)
- .markdownTheme(.custom(fontSize: chatFontSize))
- .markdownCodeSyntaxHighlighter(
- ChatCodeSyntaxHighlighter(
- brightMode: colorScheme != .dark,
- fontSize: chatCodeFontSize
+ WithPerceptionTracking {
+ let text = message.text
+ let markdownContent = message.markdownContent
+ switch message.role {
+ case .user:
+ UserMessage(
+ id: message.id,
+ text: text,
+ markdownContent: markdownContent,
+ chat: chat
)
- )
- .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.1), radius: 2)
- .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))
- }
+ .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()
}
+ }
}
}
-private struct BotMessage: View {
- let id: String
- let text: String
+private struct StopRespondingButton: View {
let chat: StoreOf
- @Environment(\.colorScheme) var colorScheme
- @AppStorage(\.chatFontSize) var chatFontSize
- @AppStorage(\.chatCodeFontSize) var chatCodeFontSize
var body: some View {
- HStack(alignment: .bottom, spacing: 2) {
- 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)
- }
- .overlay {
- RoundedCorners(tl: r, tr: r, bl: 0, br: r)
- .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
- }
- .padding(.leading, 8)
- .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.send(.setAsExtraPromptButtonTapped(id))
+ WithPerceptionTracking {
+ if chat.isReceivingMessage {
+ Button(action: {
+ chat.send(.stopRespondingButtonTapped)
+ }) {
+ HStack(spacing: 4) {
+ Image(systemName: "stop.fill")
+ Text("Stop Responding")
}
-
- Divider()
-
- Button("Delete") {
- chat.send(.deleteMessageButtonTapped(id))
+ .padding(8)
+ .background(
+ .regularMaterial,
+ in: RoundedRectangle(cornerRadius: r, style: .continuous)
+ )
+ .overlay {
+ RoundedRectangle(cornerRadius: r, style: .continuous)
+ .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
}
}
-
- CopyButton {
- NSPasteboard.general.clearContents()
- NSPasteboard.general.setString(text, forType: .string)
+ .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
+ ))
}
}
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding(.trailing, 2)
- }
-}
-
-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)
}
}
struct ChatPanelInputArea: View {
let chat: StoreOf
- @FocusState var isInputAreaFocused: Bool
+ @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)
@@ -464,185 +396,85 @@ struct ChatPanelInputArea: View {
.buttonStyle(.plain)
}
- @MainActor
- var textEditor: some View {
- HStack(spacing: 0) {
- WithViewStore(chat, removeDuplicates: { $0.typedMessage == $1.typedMessage }) {
- viewStore in
- ZStack(alignment: .center) {
- // a hack to support dynamic height of TextEditor
- Text(
- viewStore.state.typedMessage.isEmpty ? "Hi" : viewStore.state.typedMessage
- ).opacity(0)
- .font(.system(size: 14))
- .frame(maxWidth: .infinity, maxHeight: 400)
- .padding(.top, 1)
- .padding(.bottom, 2)
- .padding(.horizontal, 4)
-
- CustomTextEditor(
- text: viewStore.$typedMessage,
+ 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),
- onSubmit: { viewStore.send(.sendButtonTapped) },
+ isEditable: true,
+ maxHeight: 400,
+ onSubmit: { chat.send(.sendButtonTapped) },
completions: chatAutoCompletion
)
- .padding(.top, 1)
- .padding(.bottom, -1)
+ .focused(focusedField, equals: .textField)
+ .bind($chat.focusedField, to: focusedField)
+ .padding(8)
+ .fixedSize(horizontal: false, vertical: true)
+
+ Button(action: {
+ chat.send(.sendButtonTapped)
+ }) {
+ Image(systemName: "paperplane.fill")
+ .padding(8)
+ }
+ .buttonStyle(.plain)
+ .disabled(chat.isReceivingMessage)
+ .keyboardShortcut(KeyEquivalent.return, modifiers: [])
}
- .focused($isInputAreaFocused)
- .padding(8)
- .fixedSize(horizontal: false, vertical: true)
- }
-
- WithViewStore(chat, observe: \.isReceivingMessage) { viewStore in
- Button(action: {
- viewStore.send(.sendButtonTapped)
- }) {
- Image(systemName: "paperplane.fill")
- .padding(8)
+ .frame(maxWidth: .infinity)
+ .background {
+ RoundedRectangle(cornerRadius: 6)
+ .fill(Color(nsColor: .controlBackgroundColor))
}
- .buttonStyle(.plain)
- .disabled(viewStore.state)
- .keyboardShortcut(KeyEquivalent.return, modifiers: [])
- }
- }
- .frame(maxWidth: .infinity)
- .background {
- RoundedRectangle(cornerRadius: 6)
- .fill(Color(nsColor: .controlBackgroundColor))
- }
- .overlay {
- RoundedRectangle(cornerRadius: 6)
- .stroke(Color(nsColor: .controlColor), lineWidth: 1)
- }
- .background {
- Button(action: {
- chat.send(.returnButtonTapped)
- }) {
- EmptyView()
- }
- .keyboardShortcut(KeyEquivalent.return, modifiers: [.shift])
-
- Button(action: {
- isInputAreaFocused = true
- }) {
- EmptyView()
- }
- .keyboardShortcut("l", modifiers: [.command])
- }
- }
+ .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 = [String]() // chat.pluginIdentifiers.map { "/\($0)" }
- let availableFeatures = plugins + [
- "/exit",
- "@code",
- "@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...])
+ 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
}
}
}
@@ -650,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",
@@ -664,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,
@@ -692,14 +554,15 @@ struct ChatPanel_Preview: PreviewProvider {
```objectivec
- (void)bar {}
```
- """#
+ """#,
+ references: []
),
]
static var previews: some View {
ChatPanel(chat: .init(
initialState: .init(history: ChatPanel_Preview.history, isReceivingMessage: true),
- reducer: Chat(service: .init())
+ reducer: { Chat(service: .init()) }
))
.frame(width: 450, height: 1200)
.colorScheme(.dark)
@@ -709,8 +572,8 @@ struct ChatPanel_Preview: PreviewProvider {
struct ChatPanel_EmptyChat_Preview: PreviewProvider {
static var previews: some View {
ChatPanel(chat: .init(
- initialState: .init(history: [], isReceivingMessage: false),
- reducer: Chat(service: .init())
+ initialState: .init(history: [DisplayedChatMessage](), isReceivingMessage: false),
+ reducer: { Chat(service: .init()) }
))
.padding()
.frame(width: 450, height: 600)
@@ -718,31 +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(
initialState: .init(history: ChatPanel_Preview.history, isReceivingMessage: false),
- reducer: Chat(service: .init())
+ reducer: { Chat(service: .init()) }
))
.padding()
.frame(width: 450, height: 600)
@@ -760,7 +603,7 @@ struct ChatPanel_InputMultilineText_Preview: PreviewProvider {
history: ChatPanel_Preview.history,
isReceivingMessage: false
),
- reducer: Chat(service: .init())
+ reducer: { Chat(service: .init()) }
)
)
.padding()
@@ -773,7 +616,7 @@ struct ChatPanel_Light_Preview: PreviewProvider {
static var previews: some View {
ChatPanel(chat: .init(
initialState: .init(history: ChatPanel_Preview.history, isReceivingMessage: true),
- reducer: Chat(service: .init())
+ reducer: { Chat(service: .init()) }
))
.padding()
.frame(width: 450, height: 600)
diff --git a/Core/Sources/ChatGPTChatTab/CodeBlockHighlighter.swift b/Core/Sources/ChatGPTChatTab/CodeBlockHighlighter.swift
new file mode 100644
index 00000000..0e506b96
--- /dev/null
+++ b/Core/Sources/ChatGPTChatTab/CodeBlockHighlighter.swift
@@ -0,0 +1,117 @@
+import Combine
+import ComposableArchitecture
+import DebounceFunction
+import Foundation
+import MarkdownUI
+import Perception
+import SharedUIComponents
+import SwiftUI
+
+/// Use this instead of the built in ``CodeBlockView`` to highlight code blocks asynchronously,
+/// so that the UI doesn't freeze when rendering large code blocks.
+struct AsyncCodeBlockView: View {
+ @Perceptible
+ class Storage {
+ static let queue = DispatchQueue(
+ label: "chat-code-block-highlight",
+ qos: .userInteractive,
+ attributes: .concurrent
+ )
+
+ var highlighted: AttributedString?
+ @PerceptionIgnored var debounceFunction: DebounceFunction?
+ @PerceptionIgnored private var highlightTask: Task?
+
+ init() {
+ debounceFunction = .init(duration: 0.5, block: { [weak self] view in
+ self?.highlight(for: view)
+ })
+ }
+
+ func highlight(debounce: Bool, for view: AsyncCodeBlockView) {
+ if debounce {
+ Task { await debounceFunction?(view) }
+ } else {
+ highlight(for: view)
+ }
+ }
+
+ func highlight(for view: AsyncCodeBlockView) {
+ highlightTask?.cancel()
+ let content = view.content
+ let language = view.fenceInfo ?? ""
+ let brightMode = view.colorScheme != .dark
+ let font = CodeHighlighting.SendableFont(font: view.font)
+ highlightTask = Task {
+ let string = await withUnsafeContinuation { continuation in
+ Self.queue.async {
+ let content = CodeHighlighting.highlightedCodeBlock(
+ code: content,
+ language: language,
+ scenario: "chat",
+ brightMode: brightMode,
+ font:font
+ )
+ continuation.resume(returning: AttributedString(content))
+ }
+ }
+ try Task.checkCancellation()
+ await MainActor.run {
+ self.highlighted = string
+ }
+ }
+ }
+ }
+
+ let fenceInfo: String?
+ let content: String
+ let font: NSFont
+
+ @Environment(\.colorScheme) var colorScheme
+ @State var storage = Storage()
+ @AppStorage(\.syncChatCodeHighlightTheme) var syncCodeHighlightTheme
+ @AppStorage(\.codeForegroundColorLight) var codeForegroundColorLight
+ @AppStorage(\.codeBackgroundColorLight) var codeBackgroundColorLight
+ @AppStorage(\.codeForegroundColorDark) var codeForegroundColorDark
+ @AppStorage(\.codeBackgroundColorDark) var codeBackgroundColorDark
+
+ init(fenceInfo: String?, content: String, font: NSFont) {
+ self.fenceInfo = fenceInfo
+ self.content = content.hasSuffix("\n") ? String(content.dropLast()) : content
+ self.font = font
+ }
+
+ var body: some View {
+ WithPerceptionTracking {
+ Group {
+ if let highlighted = storage.highlighted {
+ Text(highlighted)
+ } else {
+ Text(content).font(.init(font))
+ }
+ }
+ .onAppear {
+ storage.highlight(debounce: false, for: self)
+ }
+ .onChange(of: colorScheme) { _ in
+ storage.highlight(debounce: false, for: self)
+ }
+ .onChange(of: syncCodeHighlightTheme) { _ in
+ storage.highlight(debounce: true, for: self)
+ }
+ .onChange(of: codeForegroundColorLight) { _ in
+ storage.highlight(debounce: true, for: self)
+ }
+ .onChange(of: codeBackgroundColorLight) { _ in
+ storage.highlight(debounce: true, for: self)
+ }
+ .onChange(of: codeForegroundColorDark) { _ in
+ storage.highlight(debounce: true, for: self)
+ }
+ .onChange(of: codeBackgroundColorDark) { _ in
+ storage.highlight(debounce: true, for: self)
+ }
+ }
+ }
+}
+
diff --git a/Core/Sources/ChatGPTChatTab/Styles.swift b/Core/Sources/ChatGPTChatTab/Styles.swift
index 2fa6c6be..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)
}))
}
}
@@ -34,9 +34,10 @@ extension NSAppearance {
}
extension View {
+ var messageBubbleCornerRadius: Double { 8 }
+
func codeBlockLabelStyle() -> some View {
- self
- .relativeLineSpacing(.em(0.225))
+ relativeLineSpacing(.em(0.225))
.markdownTextStyle {
FontFamilyVariant(.monospaced)
FontSize(.em(0.85))
@@ -44,16 +45,19 @@ extension View {
.padding(16)
.padding(.top, 14)
}
-
- func codeBlockStyle(_ configuration: CodeBlockConfiguration) -> some View {
- self
- .background(Color(nsColor: .textBackgroundColor).opacity(0.7))
+
+ 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(.tertiary)
- .font(.callout)
+ .foregroundStyle(labelColor)
+ .font(.callout.bold())
.padding(.leading, 8)
.lineLimit(1)
Spacer()
@@ -63,61 +67,10 @@ extension View {
}
}
}
- .markdownMargin(top: 4, bottom: 16)
- }
-}
-
-extension MarkdownUI.Theme {
- static func custom(fontSize: Double) -> MarkdownUI.Theme {
- .gitHub.text {
- ForegroundColor(.primary)
- BackgroundColor(Color.clear)
- FontSize(fontSize)
- }
- .codeBlock { configuration in
- let wrapCode = UserDefaults.shared.value(for: \.wrapCodeInChatCodeBlock)
-
- if wrapCode {
- configuration.label
- .codeBlockLabelStyle()
- .codeBlockStyle(configuration)
- } else {
- ScrollView(.horizontal) {
- configuration.label
- .codeBlockLabelStyle()
- }
- .workaroundForVerticalScrollingBugInMacOS()
- .codeBlockStyle(configuration)
+ .overlay {
+ RoundedRectangle(cornerRadius: 6).stroke(Color.primary.opacity(0.05), lineWidth: 1)
}
- }
- }
-
- 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)
- }
+ .markdownMargin(top: 4, bottom: 16)
}
}
@@ -156,3 +109,59 @@ extension View {
}
}
+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/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 2be2c2f8..e1b0eb54 100644
--- a/Core/Sources/ChatService/ChatService.swift
+++ b/Core/Sources/ChatService/ChatService.swift
@@ -1,25 +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
@@ -37,20 +41,26 @@ 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
@@ -60,28 +70,46 @@ public final class ChatService: ObservableObject {
}
}
}
-
+
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 {
@@ -97,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()
}
}
@@ -115,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 {
@@ -191,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)] " }
@@ -224,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))
@@ -239,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 4757fbec..11ae9753 100644
--- a/Core/Sources/ChatService/DynamicContextController.swift
+++ b/Core/Sources/ChatService/DynamicContextController.swift
@@ -8,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(
@@ -28,7 +28,7 @@ final class DynamicContextController {
init(
memory: AutoManagedChatGPTMemory,
functionProvider: ChatFunctionProvider,
- configuration: ChatGPTConfiguration,
+ configuration: OverridingChatGPTConfiguration,
contextCollectors: [ChatContextCollector]
) {
self.memory = memory
@@ -37,11 +37,38 @@ 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
@@ -65,28 +92,30 @@ final class DynamicContextController {
return contexts
}
- let extraSystemPrompt = contexts
+ let contextSystemPrompt = contexts
.map(\.systemPrompt)
.filter { !$0.isEmpty }
.joined(separator: "\n\n")
- let contextPrompts = contexts
+ let retrievedContent = contexts
.flatMap(\.retrievedContent)
- .filter { !$0.content.isEmpty }
+ .filter { !$0.document.content.isEmpty }
.sorted { $0.priority > $1.priority }
+ .prefix(15)
let contextualSystemPrompt = """
\(language.isEmpty ? "" : "You must always reply in \(language)")
- \(systemPrompt)\(extraSystemPrompt.isEmpty ? "" : "\n\(extraSystemPrompt)")
- """
+ \(systemPrompt)
+ """.trimmingCharacters(in: .whitespacesAndNewlines)
await memory.mutateSystemPrompt(contextualSystemPrompt)
- await memory.mutateRetrievedContent(contextPrompts.map(\.content))
+ 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 {
+ 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/HostApp/AccountSettings/APIKeyManagement/APIKeyManagementView.swift b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManagementView.swift
index 48bd8632..bc6c910e 100644
--- a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManagementView.swift
+++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManagementView.swift
@@ -3,51 +3,53 @@ import SharedUIComponents
import SwiftUI
struct APIKeyManagementView: View {
- let store: StoreOf
+ @Perception.Bindable var store: StoreOf
var body: some View {
- VStack(spacing: 0) {
- HStack {
- Button(action: {
- store.send(.closeButtonClicked)
- }) {
- Image(systemName: "xmark.circle.fill")
- .foregroundStyle(.secondary)
- .padding()
- }
- .buttonStyle(.plain)
- Text("API Keys")
- Spacer()
- Button(action: {
- store.send(.addButtonClicked)
- }) {
- Image(systemName: "plus.circle.fill")
- .foregroundStyle(.secondary)
- .padding()
+ WithPerceptionTracking {
+ VStack(spacing: 0) {
+ HStack {
+ Button(action: {
+ store.send(.closeButtonClicked)
+ }) {
+ Image(systemName: "xmark.circle.fill")
+ .foregroundStyle(.secondary)
+ .padding()
+ }
+ .buttonStyle(.plain)
+ Text("API Keys")
+ Spacer()
+ Button(action: {
+ store.send(.addButtonClicked)
+ }) {
+ Image(systemName: "plus.circle.fill")
+ .foregroundStyle(.secondary)
+ .padding()
+ }
+ .buttonStyle(.plain)
}
- .buttonStyle(.plain)
- }
- .background(Color(nsColor: .separatorColor))
+ .background(Color(nsColor: .separatorColor))
- List {
- WithViewStore(store, observe: { $0.availableAPIKeyNames }) { viewStore in
- ForEach(viewStore.state, id: \.self) { name in
- HStack {
- Text(name)
- .contextMenu {
- Button("Remove") {
- viewStore.send(.deleteButtonClicked(name: name))
+ List {
+ ForEach(store.availableAPIKeyNames, id: \.self) { name in
+ WithPerceptionTracking {
+ HStack {
+ Text(name)
+ .contextMenu {
+ Button("Remove") {
+ store.send(.deleteButtonClicked(name: name))
+ }
}
- }
- Spacer()
+ Spacer()
- Button(action: {
- viewStore.send(.deleteButtonClicked(name: name))
- }) {
- Image(systemName: "trash.fill")
- .foregroundStyle(.secondary)
+ Button(action: {
+ store.send(.deleteButtonClicked(name: name))
+ }) {
+ Image(systemName: "trash.fill")
+ .foregroundStyle(.secondary)
+ }
+ .buttonStyle(.plain)
}
- .buttonStyle(.plain)
}
}
.modify { view in
@@ -58,11 +60,9 @@ struct APIKeyManagementView: View {
}
}
}
- }
- .removeBackground()
- .overlay {
- WithViewStore(store, observe: { $0.availableAPIKeyNames }) { viewStore in
- if viewStore.state.isEmpty {
+ .removeBackground()
+ .overlay {
+ if store.availableAPIKeyNames.isEmpty {
Text("""
Empty
Add a new key by clicking the add button
@@ -72,52 +72,53 @@ struct APIKeyManagementView: View {
}
}
}
- }
- .focusable(false)
- .frame(width: 300, height: 400)
- .background(.thickMaterial)
- .onAppear {
- store.send(.appear)
- }
- .sheet(store: store.scope(
- state: \.$apiKeySubmission,
- action: APIKeyManagement.Action.apiKeySubmission
- )) { store in
- APIKeySubmissionView(store: store)
- .frame(minWidth: 400)
+ .focusable(false)
+ .frame(width: 300, height: 400)
+ .background(.thickMaterial)
+ .onAppear {
+ store.send(.appear)
+ }
+ .sheet(item: $store.scope(
+ state: \.apiKeySubmission,
+ action: \.apiKeySubmission
+ )) { store in
+ WithPerceptionTracking {
+ APIKeySubmissionView(store: store)
+ .frame(minWidth: 400)
+ }
+ }
}
}
}
struct APIKeySubmissionView: View {
- let store: StoreOf
+ @Perception.Bindable var store: StoreOf
var body: some View {
- ScrollView {
- VStack(spacing: 0) {
- Form {
- WithViewStore(store, removeDuplicates: { $0.name == $1.name }) { viewStore in
- TextField("Name", text: viewStore.$name)
- }
- WithViewStore(store, removeDuplicates: { $0.key == $1.key }) { viewStore in
- SecureField("Key", text: viewStore.$key)
+ WithPerceptionTracking {
+ ScrollView {
+ VStack(spacing: 0) {
+ Form {
+ TextField("Name", text: $store.name)
+ SecureField("Key", text: $store.key)
}
- }.padding()
+ .padding()
- Divider()
+ Divider()
- HStack {
- Spacer()
+ HStack {
+ Spacer()
- Button("Cancel") { store.send(.cancelButtonClicked) }
- .keyboardShortcut(.cancelAction)
+ Button("Cancel") { store.send(.cancelButtonClicked) }
+ .keyboardShortcut(.cancelAction)
- Button("Save", action: { store.send(.saveButtonClicked) })
- .keyboardShortcut(.defaultAction)
- }.padding()
+ Button("Save", action: { store.send(.saveButtonClicked) })
+ .keyboardShortcut(.defaultAction)
+ }.padding()
+ }
}
+ .textFieldStyle(.roundedBorder)
}
- .textFieldStyle(.roundedBorder)
}
}
@@ -128,7 +129,7 @@ class APIKeyManagementView_Preview: PreviewProvider {
initialState: .init(
availableAPIKeyNames: ["test1", "test2"]
),
- reducer: APIKeyManagement()
+ reducer: { APIKeyManagement() }
)
)
}
@@ -139,7 +140,7 @@ class APIKeySubmissionView_Preview: PreviewProvider {
APIKeySubmissionView(
store: .init(
initialState: .init(),
- reducer: APIKeySubmission()
+ reducer: { APIKeySubmission() }
)
)
}
diff --git a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManangement.swift b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManangement.swift
index 3ff3188e..2756ce1e 100644
--- a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManangement.swift
+++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManangement.swift
@@ -1,10 +1,12 @@
import ComposableArchitecture
import Foundation
-struct APIKeyManagement: ReducerProtocol {
+@Reducer
+struct APIKeyManagement {
+ @ObservableState
struct State: Equatable {
var availableAPIKeyNames: [String] = []
- @PresentationState var apiKeySubmission: APIKeySubmission.State?
+ @Presents var apiKeySubmission: APIKeySubmission.State?
}
enum Action: Equatable {
@@ -20,7 +22,7 @@ struct APIKeyManagement: ReducerProtocol {
@Dependency(\.toast) var toast
@Dependency(\.apiKeyKeychain) var keychain
- var body: some ReducerProtocol {
+ var body: some ReducerOf {
Reduce { state, action in
switch action {
case .appear:
@@ -72,7 +74,7 @@ struct APIKeyManagement: ReducerProtocol {
return .none
}
}
- .ifLet(\.$apiKeySubmission, action: /Action.apiKeySubmission) {
+ .ifLet(\.$apiKeySubmission, action: \.apiKeySubmission) {
APIKeySubmission()
}
}
diff --git a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyPicker.swift b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyPicker.swift
index a18e0a4c..57e853d4 100644
--- a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyPicker.swift
+++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyPicker.swift
@@ -2,26 +2,27 @@ import ComposableArchitecture
import SwiftUI
struct APIKeyPicker: View {
- let store: StoreOf
+ @Perception.Bindable var store: StoreOf
var body: some View {
- WithViewStore(store) { viewStore in
+ WithPerceptionTracking {
HStack {
Picker(
- selection: viewStore.$apiKeyName,
+ selection: $store.apiKeyName,
content: {
Text("No API Key").tag("")
- if viewStore.state.availableAPIKeyNames.isEmpty {
+ if store.availableAPIKeyNames.isEmpty {
Text("No API key found, please add a new one →")
}
-
- if !viewStore.state.availableAPIKeyNames.contains(viewStore.state.apiKeyName),
- !viewStore.state.apiKeyName.isEmpty {
- Text("Key not found: \(viewStore.state.apiKeyName)")
- .tag(viewStore.state.apiKeyName)
+
+ if !store.availableAPIKeyNames.contains(store.apiKeyName),
+ !store.apiKeyName.isEmpty
+ {
+ Text("Key not found: \(store.apiKeyName)")
+ .tag(store.apiKeyName)
}
-
- ForEach(viewStore.state.availableAPIKeyNames, id: \.self) { name in
+
+ ForEach(store.availableAPIKeyNames, id: \.self) { name in
Text(name).tag(name)
}
@@ -32,15 +33,17 @@ struct APIKeyPicker: View {
Button(action: { store.send(.manageAPIKeysButtonClicked) }) {
Text(Image(systemName: "key"))
}
- }.sheet(isPresented: viewStore.$isAPIKeyManagementPresented) {
- APIKeyManagementView(store: store.scope(
- state: \.apiKeyManagement,
- action: APIKeySelection.Action.apiKeyManagement
- ))
+ }.sheet(isPresented: $store.isAPIKeyManagementPresented) {
+ WithPerceptionTracking {
+ APIKeyManagementView(store: store.scope(
+ state: \.apiKeyManagement,
+ action: \.apiKeyManagement
+ ))
+ }
+ }
+ .onAppear {
+ store.send(.appear)
}
- }
- .onAppear {
- store.send(.appear)
}
}
}
diff --git a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySelection.swift b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySelection.swift
index 75e2d77c..47e8b33b 100644
--- a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySelection.swift
+++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySelection.swift
@@ -2,14 +2,16 @@ import Foundation
import SwiftUI
import ComposableArchitecture
-struct APIKeySelection: ReducerProtocol {
+@Reducer
+struct APIKeySelection {
+ @ObservableState
struct State: Equatable {
- @BindingState var apiKeyName: String = ""
+ var apiKeyName: String = ""
var availableAPIKeyNames: [String] {
apiKeyManagement.availableAPIKeyNames
}
var apiKeyManagement: APIKeyManagement.State = .init()
- @BindingState var isAPIKeyManagementPresented: Bool = false
+ var isAPIKeyManagementPresented: Bool = false
}
enum Action: Equatable, BindableAction {
@@ -23,10 +25,10 @@ struct APIKeySelection: ReducerProtocol {
@Dependency(\.toast) var toast
@Dependency(\.apiKeyKeychain) var keychain
- var body: some ReducerProtocol {
+ var body: some ReducerOf {
BindingReducer()
- Scope(state: \.apiKeyManagement, action: /Action.apiKeyManagement) {
+ Scope(state: \.apiKeyManagement, action: \.apiKeyManagement) {
APIKeyManagement()
}
diff --git a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySubmission.swift b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySubmission.swift
index 64f16b7d..8fe390ee 100644
--- a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySubmission.swift
+++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySubmission.swift
@@ -1,10 +1,12 @@
import ComposableArchitecture
import Foundation
-struct APIKeySubmission: ReducerProtocol {
+@Reducer
+struct APIKeySubmission {
+ @ObservableState
struct State: Equatable {
- @BindingState var name: String = ""
- @BindingState var key: String = ""
+ var name: String = ""
+ var key: String = ""
}
enum Action: Equatable, BindableAction {
@@ -22,7 +24,7 @@ struct APIKeySubmission: ReducerProtocol {
case keyIsEmpty
}
- var body: some ReducerProtocol {
+ var body: some ReducerOf {
BindingReducer()
Reduce { state, action in
diff --git a/Core/Sources/HostApp/AccountSettings/BingSearchView.swift b/Core/Sources/HostApp/AccountSettings/BingSearchView.swift
deleted file mode 100644
index 408dbd55..00000000
--- a/Core/Sources/HostApp/AccountSettings/BingSearchView.swift
+++ /dev/null
@@ -1,51 +0,0 @@
-import AppKit
-import Client
-import OpenAIService
-import Preferences
-import SuggestionModel
-import SwiftUI
-
-final class BingSearchViewSettings: ObservableObject {
- @AppStorage(\.bingSearchSubscriptionKey) var bingSearchSubscriptionKey: String
- @AppStorage(\.bingSearchEndpoint) var bingSearchEndpoint: String
- init() {}
-}
-
-struct BingSearchView: View {
- @Environment(\.openURL) var openURL
- @StateObject var settings = BingSearchViewSettings()
-
- var body: some View {
- Form {
- Button(action: {
- let url = URL(string: "https://www.microsoft.com/bing/apis/bing-web-search-api")!
- openURL(url)
- }) {
- Text("Apply for Subscription Key for Free")
- }
-
- SecureField(text: $settings.bingSearchSubscriptionKey, prompt: Text("")) {
- Text("Bing Search Subscription Key")
- }
- .textFieldStyle(.roundedBorder)
-
- TextField(
- text: $settings.bingSearchEndpoint,
- prompt: Text("https://api.bing.microsoft.com/***")
- ) {
- Text("Bing Search Endpoint")
- }.textFieldStyle(.roundedBorder)
- }
- }
-}
-
-struct BingSearchView_Previews: PreviewProvider {
- static var previews: some View {
- VStack(alignment: .leading, spacing: 8) {
- BingSearchView()
- }
- .frame(height: 800)
- .padding(.all, 8)
- }
-}
-
diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift
index 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 6101de58..e81b4a97 100644
--- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagementView.swift
+++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagementView.swift
@@ -3,17 +3,19 @@ import ComposableArchitecture
import SwiftUI
struct ChatModelManagementView: View {
- let store: StoreOf
+ @Perception.Bindable var store: StoreOf
var body: some View {
- AIModelManagementView(store: store)
- .sheet(store: store.scope(
- state: \.$editingModel,
- action: ChatModelManagement.Action.chatModelItem
- )) { store in
- ChatModelEditView(store: store)
- .frame(width: 800)
- }
+ WithPerceptionTracking {
+ AIModelManagementView(store: store)
+ .sheet(item: $store.scope(
+ state: \.editingModel,
+ action: \.chatModelItem
+ )) { store in
+ ChatModelEditView(store: store)
+ .frame(width: 800)
+ }
+ }
}
}
@@ -62,23 +64,22 @@ class ChatModelManagementView_Previews: PreviewProvider {
)
),
]),
- editingModel: .init(
- model: ChatModel(
- id: "3",
- name: "Test Model 3",
- format: .openAICompatible,
- info: .init(
- apiKeyName: "key",
- baseURL: "apple.com",
- maxTokens: 3000,
- supportsFunctionCalling: false,
- modelName: "gpt-3.5-turbo"
- )
+ editingModel: ChatModel(
+ id: "3",
+ name: "Test Model 3",
+ format: .openAICompatible,
+ info: .init(
+ apiKeyName: "key",
+ baseURL: "apple.com",
+ maxTokens: 3000,
+ supportsFunctionCalling: false,
+ modelName: "gpt-3.5-turbo"
)
- )
+ ).toState()
),
- reducer: ChatModelManagement()
+ reducer: { ChatModelManagement() }
)
)
}
}
+
diff --git a/Core/Sources/HostApp/AccountSettings/CodeiumView.swift b/Core/Sources/HostApp/AccountSettings/CodeiumView.swift
index 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 621ed75d..2c1fd2d7 100644
--- a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/AIModelManagementVIew.swift
+++ b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/AIModelManagementVIew.swift
@@ -20,9 +20,9 @@ protocol AIModelManagementState: Equatable {
var selectedModelId: Model.ID? { get }
}
-protocol AIModelManagement: ReducerProtocol where
+protocol AIModelManagement: Reducer where
Action: AIModelManagementAction,
- State: AIModelManagementState,
+ State: AIModelManagementState & ObservableState,
Action.Model == Self.Model,
State.Model == Self.Model
{
@@ -39,69 +39,71 @@ protocol ManageableAIModel: Identifiable {
struct AIModelManagementView: View
where Management.Model == Model
{
- let store: StoreOf
+ @Perception.Bindable var store: StoreOf
var body: some View {
- VStack(spacing: 0) {
- HStack {
- Spacer()
- if isFeatureAvailable(\.unlimitedChatAndEmbeddingModels) {
- Button("Add Model") {
- store.send(.createModel)
- }
- } else {
- WithViewStore(store, observe: { $0.models.count }) { viewStore in
- Text("\(viewStore.state) / 2")
+ WithPerceptionTracking {
+ VStack(spacing: 0) {
+ HStack {
+ Spacer()
+ if isFeatureAvailable(\.unlimitedChatAndEmbeddingModels) {
+ Button("Add Model") {
+ store.send(.createModel)
+ }
+ } else {
+ Text("\(store.models.count) / 2")
.foregroundColor(.secondary)
- let disabled = viewStore.state >= 2
+ let disabled = store.models.count >= 2
Button(disabled ? "Add More Model (Plus)" : "Add Model") {
store.send(.createModel)
}.disabled(disabled)
}
- }
- }.padding(4)
+ }.padding(4)
- Divider()
+ Divider()
- ModelList(store: store)
- }
- .onAppear {
- store.send(.appear)
+ ModelList(store: store)
+ }
+ .onAppear {
+ store.send(.appear)
+ }
}
}
struct ModelList: View {
- let store: StoreOf
+ @Perception.Bindable var store: StoreOf
var body: some View {
- WithViewStore(store) { viewStore in
+ WithPerceptionTracking {
List {
- ForEach(viewStore.state.models) { model in
- let isSelected = viewStore.state.selectedModelId == model.id
- HStack(spacing: 4) {
- Image(systemName: "line.3.horizontal")
+ ForEach(store.models) { model in
+ WithPerceptionTracking {
+ let isSelected = store.selectedModelId == model.id
+ HStack(spacing: 4) {
+ Image(systemName: "line.3.horizontal")
- Button(action: {
- viewStore.send(.selectModel(id: model.id))
- }) {
- Cell(model: model, isSelected: isSelected)
- .contentShape(Rectangle())
- }
- .buttonStyle(.plain)
- .contextMenu {
- Button("Duplicate") {
- store.send(.duplicateModel(id: model.id))
+ Button(action: {
+ store.send(.selectModel(id: model.id))
+ }) {
+ Cell(model: model, isSelected: isSelected)
+ .contentShape(Rectangle())
}
- Button("Remove") {
- store.send(.removeModel(id: model.id))
+ .buttonStyle(.plain)
+ .contextMenu {
+ Button("Duplicate") {
+ store.send(.duplicateModel(id: model.id))
+ }
+ Button("Remove") {
+ store.send(.removeModel(id: model.id))
+ }
}
}
}
}
.onMove(perform: { indices, newOffset in
- viewStore.send(.moveModel(from: indices, to: newOffset))
+ store.send(.moveModel(from: indices, to: newOffset))
})
.modify { view in
if #available(macOS 13.0, *) {
@@ -115,7 +117,7 @@ struct AIModelManagementView(
store: .init(
- initialState: .init(models: []),
- reducer: ChatModelManagement()
+ initialState: .init(models: [] as IdentifiedArrayOf),
+ reducer: { ChatModelManagement() }
)
)
}
diff --git a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLPicker.swift b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLPicker.swift
index 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 c7aaea39..033b9850 100644
--- a/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift
+++ b/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift
@@ -2,8 +2,9 @@ import ComposableArchitecture
import MarkdownUI
import PlusFeatureFlag
import Preferences
-import SwiftUI
import SharedUIComponents
+import SwiftUI
+import Toast
extension List {
@ViewBuilder
@@ -20,9 +21,7 @@ extension List {
let customCommandStore = StoreOf(
initialState: .init(),
- reducer: CustomCommandFeature(
- settings: .init()
- )
+ reducer: { CustomCommandFeature(settings: .init()) }
)
struct CustomCommandView: View {
@@ -42,140 +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)
- })
- .modify { view in
- if #available(macOS 13.0, *) {
- view.listRowSeparator(.hidden).listSectionSeparator(.hidden)
- } else {
- view
+ struct LeftPanel: View {
+ let store: StoreOf
+ @ObservedObject var settings: Settings
+ @Environment(\.toast) var toast
+
+ var body: some View {
+ WithPerceptionTracking {
+ List {
+ ForEach(settings.customCommands, id: \.commandId) { command in
+ CommandButton(store: store, command: command)
+ }
+ .onMove(perform: { indices, newOffset in
+ settings.customCommands.move(fromOffsets: indices, toOffset: newOffset)
+ })
+ .modify { view in
+ if #available(macOS 13.0, *) {
+ view.listRowSeparator(.hidden).listSectionSeparator(.hidden)
+ } else {
+ view
+ }
+ }
}
+ .removeBackground()
+ .padding(.vertical, 4)
+ .listStyle(.plain)
+ .frame(width: 200)
+ .background(Color.primary.opacity(0.05))
+ .overlay {
+ if settings.customCommands.isEmpty {
+ Text("""
+ Empty
+ Add command with "+" button
+ """)
+ .multilineTextAlignment(.center)
+ }
+ }
+ .safeAreaInset(edge: .bottom) {
+ Button(action: {
+ store.send(.createNewCommand)
+ }) {
+ if isFeatureAvailable(\.unlimitedCustomCommands) {
+ Text(Image(systemName: "plus.circle.fill")) + Text(" New Command")
+ } else {
+ Text(Image(systemName: "plus.circle.fill")) +
+ Text(" New Command (\(settings.customCommands.count)/10)")
+ }
+ }
+ .buttonStyle(.plain)
+ .padding()
+ .contextMenu {
+ Button("Import") {
+ store.send(.importCommandClicked)
+ }
+ }
+ }
+ .onDrop(of: [.json], delegate: FileDropDelegate(store: store, toast: toast))
}
}
- .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)")
- }
+ }
+
+ 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()
+ }
+ }
}
}
}
@@ -231,7 +275,9 @@ struct CustomCommandView_Preview: PreviewProvider {
extraSystemPrompt: nil,
prompt: "Hello",
useExtraSystemPrompt: false
- )
+ ),
+ ignoreExistingAttachments: false,
+ attachments: []
),
.init(
commandId: "2",
@@ -241,7 +287,9 @@ struct CustomCommandView_Preview: PreviewProvider {
prompt: "Refactor",
continuousMode: false,
generateDescription: true
- )
+ ),
+ ignoreExistingAttachments: false,
+ attachments: []
),
], "CustomCommandView_Preview"))
@@ -255,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
)
@@ -275,7 +325,9 @@ struct CustomCommandView_NoEditing_Preview: PreviewProvider {
extraSystemPrompt: nil,
prompt: "Hello",
useExtraSystemPrompt: false
- )
+ ),
+ ignoreExistingAttachments: false,
+ attachments: []
),
.init(
commandId: "2",
@@ -285,7 +337,9 @@ struct CustomCommandView_NoEditing_Preview: PreviewProvider {
prompt: "Refactor",
continuousMode: false,
generateDescription: true
- )
+ ),
+ ignoreExistingAttachments: false,
+ attachments: []
),
], "CustomCommandView_Preview"))
@@ -294,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?
+
+ @MainActor
+ @Published
+ var openChatOptions = [OpenChatMode]()
+
+ init() {
+ Task { @MainActor in
+ refreshExtensionOpenChatHandlers()
+ }
+ refreshExtensionExtensionOpenChatHandlerTask = Task { [weak self] in
+ let sequence = NotificationCenter.default
+ .notifications(named: NSApplication.didBecomeActiveNotification)
+ for await _ in sequence {
+ guard let self else { return }
+ await MainActor.run {
+ self.refreshExtensionOpenChatHandlers()
+ }
+ }
+ }
+ }
+
+ @MainActor
+ func refreshExtensionOpenChatHandlers() {
+ guard let service = try? getService() else { return }
+ Task { @MainActor in
+ let handlers = try await service
+ .send(requestBody: ExtensionServiceRequests.GetExtensionOpenChatHandlers())
+ openChatOptions = handlers.map {
+ if $0.isBuiltIn {
+ return .builtinExtension(
+ extensionIdentifier: $0.bundleIdentifier,
+ id: $0.id,
+ tabName: $0.tabName
+ )
+ } else {
+ return .externalExtension(
+ extensionIdentifier: $0.bundleIdentifier,
+ id: $0.id,
+ tabName: $0.tabName
+ )
+ }
+ }
+ }
+ }
+ }
+
+ @Environment(\.openURL) var openURL
+ @Environment(\.toast) var toast
+ @StateObject var settings = Settings()
+ @State var maxTokenOverLimit = false
+
+ var body: some View {
+ VStack {
+ openChatSettingsForm
+ SettingsDivider("Conversation")
+ chatSettingsForm
+ SettingsDivider("UI")
+ uiForm
+ SettingsDivider("Plugin")
+ pluginForm
+ }
+ }
+
+ @ViewBuilder
+ var openChatSettingsForm: some View {
+ Form {
+ Picker(
+ "Open Chat Mode",
+ selection: .init(get: {
+ settings.openChatMode.value
+ }, set: {
+ settings.openChatMode = .init($0)
+ })
+ ) {
+ Text("Open chat panel").tag(OpenChatMode.chatPanel)
+ Text("Open web page in browser").tag(OpenChatMode.browser)
+ ForEach(settings.openChatOptions) { mode in
+ switch mode {
+ case let .builtinExtension(_, _, name):
+ Text("Open \(name) tab").tag(mode)
+ case let .externalExtension(_, _, name):
+ Text("Open \(name) tab").tag(mode)
+ default:
+ EmptyView()
+ }
+ }
+ }
+
+ if settings.openChatMode.value == .browser {
+ TextField(
+ "Chat web page URL",
+ text: $settings.openChatInBrowserURL,
+ prompt: Text("https://")
+ )
+ .textFieldStyle(.roundedBorder)
+ .disableAutocorrection(true)
+ .autocorrectionDisabled(true)
+
+ #if canImport(ProHostApp)
+ WithFeatureEnabled(\.browserTab) {
+ Toggle(
+ "Open web page in chat panel",
+ isOn: $settings.openChatInBrowserInInAppBrowser
+ )
+ }
+ #endif
+ }
+ }
+ }
+
+ @ViewBuilder
+ var chatSettingsForm: some View {
+ Form {
+ Picker(
+ "Chat model",
+ selection: $settings.defaultChatFeatureChatModelId
+ ) {
+ let allModels = settings.chatModels + [.init(
+ id: "com.github.copilot",
+ name: "GitHub Copilot Language Server",
+ format: .openAI,
+ info: .init()
+ )]
+
+ if !allModels.contains(where: { $0.id == settings.defaultChatFeatureChatModelId }) {
+ Text(
+ (allModels.first?.name).map { "\($0) (Default)" } ?? "No model found"
+ )
+ .tag(settings.defaultChatFeatureChatModelId)
+ }
+
+ ForEach(allModels, id: \.id) { chatModel in
+ Text(chatModel.name).tag(chatModel.id)
+ }
+ }
+
+ Picker(
+ "Utility chat model",
+ selection: $settings.utilityChatModelId
+ ) {
+ Text("Use the default model").tag("")
+
+ if !settings.chatModels.contains(where: { $0.id == settings.utilityChatModelId }),
+ !settings.utilityChatModelId.isEmpty
+ {
+ Text(
+ (settings.chatModels.first?.name).map { "\($0) (Default)" }
+ ?? "No Model Found"
+ )
+ .tag(settings.utilityChatModelId)
+ }
+
+ ForEach(settings.chatModels, id: \.id) { chatModel in
+ Text(chatModel.name).tag(chatModel.id)
+ }
+ }
+
+ Picker(
+ "Embedding model",
+ selection: $settings.defaultChatFeatureEmbeddingModelId
+ ) {
+ if !settings.embeddingModels
+ .contains(where: { $0.id == settings.defaultChatFeatureEmbeddingModelId })
+ {
+ Text(
+ (settings.embeddingModels.first?.name).map { "\($0) (Default)" }
+ ?? "No model found"
+ )
+ .tag(settings.defaultChatFeatureEmbeddingModelId)
+ }
+
+ ForEach(settings.embeddingModels, id: \.id) { embeddingModel in
+ Text(embeddingModel.name).tag(embeddingModel.id)
+ }
+ }
+
+ if #available(macOS 13.0, *) {
+ LabeledContent("Reply in language") {
+ languagePicker
+ }
+ } else {
+ HStack {
+ Text("Reply in language")
+ languagePicker
+ }
+ }
+
+ HStack {
+ Slider(value: $settings.chatGPTTemperature, in: 0...2, step: 0.1) {
+ Text("Temperature")
+ }
+
+ Text(
+ "\(settings.chatGPTTemperature.formatted(.number.precision(.fractionLength(1))))"
+ )
+ .font(.body)
+ .foregroundColor(settings.chatGPTTemperature >= 1 ? .red : .secondary)
+ .monospacedDigit()
+ .padding(.vertical, 2)
+ .padding(.horizontal, 6)
+ .background(
+ RoundedRectangle(cornerRadius: 4, style: .continuous)
+ .fill(Color.primary.opacity(0.1))
+ )
+ }
+
+ Picker(
+ "Memory",
+ selection: $settings.chatGPTMaxMessageCount
+ ) {
+ Text("No Limit").tag(0)
+ Text("3 Messages").tag(3)
+ Text("5 Messages").tag(5)
+ Text("7 Messages").tag(7)
+ Text("9 Messages").tag(9)
+ Text("11 Messages").tag(11)
+ Text("21 Messages").tag(21)
+ Text("31 Messages").tag(31)
+ Text("41 Messages").tag(41)
+ Text("51 Messages").tag(51)
+ Text("71 Messages").tag(71)
+ Text("91 Messages").tag(91)
+ Text("111 Messages").tag(111)
+ Text("151 Messages").tag(151)
+ Text("201 Messages").tag(201)
+ }
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Additional system prompt")
+ EditableText(text: $settings.defaultChatSystemPrompt)
+ .lineLimit(6)
+ }
+ .padding(.vertical, 4)
+ }
+ }
+
+ @ViewBuilder
+ var uiForm: some View {
+ Form {
+ HStack {
+ TextField(text: .init(get: {
+ "\(Int(settings.chatFontSize))"
+ }, set: {
+ settings.chatFontSize = Double(Int($0) ?? 0)
+ })) {
+ Text("Font size of message")
+ }
+ .textFieldStyle(.roundedBorder)
+
+ Text("pt")
+ }
+
+ FontPicker(font: $settings.chatCodeFont) {
+ Text("Font of code")
+ }
+
+ Toggle(isOn: $settings.wrapCodeInCodeBlock) {
+ Text("Wrap text in code block")
+ }
+
+ CodeHighlightThemePicker(scenario: .chat)
+
+ Picker("Always-on-top behavior", selection: $settings.chatPanelFloatOnTopOption) {
+ Text("Always").tag(UserDefaultPreferenceKeys.ChatPanelFloatOnTopOption.alwaysOnTop)
+ Text("When Xcode is active")
+ .tag(UserDefaultPreferenceKeys.ChatPanelFloatOnTopOption.onTopWhenXcodeIsActive)
+ Text("Never").tag(UserDefaultPreferenceKeys.ChatPanelFloatOnTopOption.never)
+ }
+
+ Toggle(isOn: $settings.disableFloatOnTopWhenTheChatPanelIsDetached) {
+ Text("Disable always-on-top when the chat panel is detached")
+ }.disabled(settings.chatPanelFloatOnTopOption == .never)
+
+ Toggle(isOn: $settings.keepFloatOnTopIfChatPanelAndXcodeOverlaps) {
+ Text("Keep always-on-top if the chat panel and Xcode overlaps and Xcode is active")
+ }
+ .disabled(
+ !settings.disableFloatOnTopWhenTheChatPanelIsDetached
+ || settings.chatPanelFloatOnTopOption == .never
+ )
+ }
+ }
+
+ @ViewBuilder
+ var pluginForm: some View {
+ Form {
+ TextField(text: .init(get: {
+ "\(Int(settings.chatSearchPluginMaxIterations))"
+ }, set: {
+ settings.chatSearchPluginMaxIterations = Int($0) ?? 0
+ })) {
+ Text("Search plugin max iterations")
+ }
+ .textFieldStyle(.roundedBorder)
+ }
+ }
+
+ var languagePicker: some View {
+ Menu {
+ if !settings.chatGPTLanguage.isEmpty,
+ !Settings.availableLocalizedLocales
+ .contains(settings.chatGPTLanguage)
+ {
+ Button(
+ settings.chatGPTLanguage,
+ action: { self.settings.chatGPTLanguage = settings.chatGPTLanguage }
+ )
+ }
+ Button(
+ "Auto-detected by LLM",
+ action: { self.settings.chatGPTLanguage = "" }
+ )
+ ForEach(
+ Settings.availableLocalizedLocales,
+ id: \.self
+ ) { localizedLocales in
+ Button(
+ localizedLocales,
+ action: { self.settings.chatGPTLanguage = localizedLocales }
+ )
+ }
+ } label: {
+ Text(
+ settings.chatGPTLanguage.isEmpty
+ ? "Auto-detected by LLM"
+ : settings.chatGPTLanguage
+ )
+ }
+ }
+}
+
+// MARK: - Preview
+
+//
+// #Preview {
+// ScrollView {
+// ChatSettingsView()
+// .padding()
+// }
+// .frame(height: 800)
+// .environment(\.overrideFeatureFlag, \.never)
+// }
+//
+
diff --git a/Core/Sources/HostApp/FeatureSettings/Chat/ChatSettingsView.swift b/Core/Sources/HostApp/FeatureSettings/Chat/ChatSettingsView.swift
new file mode 100644
index 00000000..8540b9d2
--- /dev/null
+++ b/Core/Sources/HostApp/FeatureSettings/Chat/ChatSettingsView.swift
@@ -0,0 +1,38 @@
+import Preferences
+import SharedUIComponents
+import SwiftUI
+
+struct ChatSettingsView: View {
+ enum Tab {
+ case general
+ }
+
+ @State var tabSelection: Tab = .general
+
+ var body: some View {
+ VStack(spacing: 0) {
+ Picker("", selection: $tabSelection) {
+ Text("General").tag(Tab.general)
+ }
+ .pickerStyle(.segmented)
+ .padding(8)
+
+ Divider()
+ .shadow(radius: 10)
+
+ ScrollView {
+ Group {
+ switch tabSelection {
+ case .general:
+ ChatSettingsGeneralSectionView()
+ }
+ }.padding()
+ }
+ }
+ }
+}
+
+#Preview {
+ ChatSettingsView()
+ .frame(width: 600, height: 500)
+}
diff --git a/Core/Sources/HostApp/FeatureSettings/ChatSettingsView.swift b/Core/Sources/HostApp/FeatureSettings/ChatSettingsView.swift
deleted file mode 100644
index 7f903728..00000000
--- a/Core/Sources/HostApp/FeatureSettings/ChatSettingsView.swift
+++ /dev/null
@@ -1,247 +0,0 @@
-import Preferences
-import SwiftUI
-
-struct ChatSettingsView: View {
- class Settings: ObservableObject {
- static let availableLocalizedLocales = Locale.availableLocalizedLocales
- @AppStorage(\.chatGPTLanguage) var chatGPTLanguage
- @AppStorage(\.chatGPTTemperature) var chatGPTTemperature
- @AppStorage(\.chatGPTMaxMessageCount) var chatGPTMaxMessageCount
- @AppStorage(\.chatFontSize) var chatFontSize
- @AppStorage(\.chatCodeFontSize) var chatCodeFontSize
- @AppStorage(\.maxFocusedCodeLineCount)
- var maxFocusedCodeLineCount
- @AppStorage(\.useCodeScopeByDefaultInChatContext)
- var useCodeScopeByDefaultInChatContext
- @AppStorage(\.defaultChatFeatureChatModelId) var defaultChatFeatureChatModelId
- @AppStorage(\.defaultChatSystemPrompt) var defaultChatSystemPrompt
- @AppStorage(\.chatSearchPluginMaxIterations) var chatSearchPluginMaxIterations
- @AppStorage(\.defaultChatFeatureEmbeddingModelId) var defaultChatFeatureEmbeddingModelId
- @AppStorage(\.chatModels) var chatModels
- @AppStorage(\.embeddingModels) var embeddingModels
- @AppStorage(\.wrapCodeInChatCodeBlock) var wrapCodeInCodeBlock
-
- init() {}
- }
-
- @Environment(\.openURL) var openURL
- @Environment(\.toast) var toast
- @StateObject var settings = Settings()
- @State var maxTokenOverLimit = false
-
- var body: some View {
- VStack {
- chatSettingsForm
- Divider()
- uiForm
- Divider()
- contextForm
- Divider()
- pluginForm
- }
- }
-
- @ViewBuilder
- var chatSettingsForm: some View {
- Form {
- Picker(
- "Chat Model",
- selection: $settings.defaultChatFeatureChatModelId
- ) {
- if !settings.chatModels
- .contains(where: { $0.id == settings.defaultChatFeatureChatModelId })
- {
- Text(
- (settings.chatModels.first?.name).map { "\($0) (Default)" }
- ?? "No Model Found"
- )
- .tag(settings.defaultChatFeatureChatModelId)
- }
-
- ForEach(settings.chatModels, id: \.id) { chatModel in
- Text(chatModel.name).tag(chatModel.id)
- }
- }
-
- Picker(
- "Embedding Model",
- selection: $settings.defaultChatFeatureEmbeddingModelId
- ) {
- if !settings.embeddingModels
- .contains(where: { $0.id == settings.defaultChatFeatureEmbeddingModelId })
- {
- Text(
- (settings.embeddingModels.first?.name).map { "\($0) (Default)" }
- ?? "No Model Found"
- )
- .tag(settings.defaultChatFeatureEmbeddingModelId)
- }
-
- ForEach(settings.embeddingModels, id: \.id) { embeddingModel in
- Text(embeddingModel.name).tag(embeddingModel.id)
- }
- }
-
- if #available(macOS 13.0, *) {
- LabeledContent("Reply in Language") {
- languagePicker
- }
- } else {
- HStack {
- Text("Reply in Language")
- languagePicker
- }
- }
-
- HStack {
- Slider(value: $settings.chatGPTTemperature, in: 0...2, step: 0.1) {
- Text("Temperature")
- }
-
- Text(
- "\(settings.chatGPTTemperature.formatted(.number.precision(.fractionLength(1))))"
- )
- .font(.body)
- .monospacedDigit()
- .padding(.vertical, 2)
- .padding(.horizontal, 6)
- .background(
- RoundedRectangle(cornerRadius: 4, style: .continuous)
- .fill(Color.primary.opacity(0.1))
- )
- }
-
- Picker(
- "Memory",
- selection: $settings.chatGPTMaxMessageCount
- ) {
- Text("No Limit").tag(0)
- Text("3 Messages").tag(3)
- Text("5 Messages").tag(5)
- Text("7 Messages").tag(7)
- Text("9 Messages").tag(9)
- Text("11 Messages").tag(11)
- }
-
- VStack(alignment: .leading, spacing: 4) {
- Text("Default System Prompt")
- EditableText(text: $settings.defaultChatSystemPrompt)
- .lineLimit(6)
- }
- .padding(.vertical, 4)
- }
- }
-
- @ViewBuilder
- var uiForm: some View {
- Form {
- HStack {
- TextField(text: .init(get: {
- "\(Int(settings.chatFontSize))"
- }, set: {
- settings.chatFontSize = Double(Int($0) ?? 0)
- })) {
- Text("Font size of message")
- }
- .textFieldStyle(.roundedBorder)
-
- Text("pt")
- }
-
- HStack {
- TextField(text: .init(get: {
- "\(Int(settings.chatCodeFontSize))"
- }, set: {
- settings.chatCodeFontSize = Double(Int($0) ?? 0)
- })) {
- Text("Font size of code block")
- }
- .textFieldStyle(.roundedBorder)
-
- Text("pt")
- }
-
- Toggle(isOn: $settings.wrapCodeInCodeBlock) {
- Text("Wrap code in code block")
- }
- }
- }
-
- @ViewBuilder
- var contextForm: some View {
- Form {
- Toggle(isOn: $settings.useCodeScopeByDefaultInChatContext) {
- Text("Use @code scope by default in chat context.")
- }
-
- HStack {
- TextField(text: .init(get: {
- "\(Int(settings.maxFocusedCodeLineCount))"
- }, set: {
- settings.maxFocusedCodeLineCount = Int($0) ?? 0
- })) {
- Text("Max focused code line count in chat context")
- }
- .textFieldStyle(.roundedBorder)
-
- Text("lines")
- }
- }
- }
-
- @ViewBuilder
- var pluginForm: some View {
- Form {
- TextField(text: .init(get: {
- "\(Int(settings.chatSearchPluginMaxIterations))"
- }, set: {
- settings.chatSearchPluginMaxIterations = Int($0) ?? 0
- })) {
- Text("Search Plugin Max Iterations")
- }
- .textFieldStyle(.roundedBorder)
- }
- }
-
- var languagePicker: some View {
- Menu {
- if !settings.chatGPTLanguage.isEmpty,
- !Settings.availableLocalizedLocales
- .contains(settings.chatGPTLanguage)
- {
- Button(
- settings.chatGPTLanguage,
- action: { self.settings.chatGPTLanguage = settings.chatGPTLanguage }
- )
- }
- Button(
- "Auto-detected by ChatGPT",
- action: { self.settings.chatGPTLanguage = "" }
- )
- ForEach(
- Settings.availableLocalizedLocales,
- id: \.self
- ) { localizedLocales in
- Button(
- localizedLocales,
- action: { self.settings.chatGPTLanguage = localizedLocales }
- )
- }
- } label: {
- Text(
- settings.chatGPTLanguage.isEmpty
- ? "Auto-detected by ChatGPT"
- : settings.chatGPTLanguage
- )
- }
- }
-}
-
-// MARK: - Preview
-
-struct ChatSettingsView_Previews: PreviewProvider {
- static var previews: some View {
- ChatSettingsView()
- }
-}
-
diff --git a/Core/Sources/HostApp/FeatureSettings/PromptToCodeSettingsView.swift b/Core/Sources/HostApp/FeatureSettings/PromptToCodeSettingsView.swift
index de42264b..f9c7f545 100644
--- a/Core/Sources/HostApp/FeatureSettings/PromptToCodeSettingsView.swift
+++ b/Core/Sources/HostApp/FeatureSettings/PromptToCodeSettingsView.swift
@@ -1,11 +1,13 @@
+import Preferences
+import SharedUIComponents
import SwiftUI
struct PromptToCodeSettingsView: View {
final class Settings: ObservableObject {
- @AppStorage(\.hideCommonPrecedingSpacesInSuggestion)
- var hideCommonPrecedingSpacesInSuggestion
- @AppStorage(\.suggestionCodeFontSize)
- var suggestionCodeFontSize
+ @AppStorage(\.hideCommonPrecedingSpacesInPromptToCode)
+ var hideCommonPrecedingSpaces
+ @AppStorage(\.promptToCodeCodeFont)
+ var font
@AppStorage(\.promptToCodeGenerateDescription)
var promptToCodeGenerateDescription
@AppStorage(\.promptToCodeGenerateDescriptionInUserPreferredLanguage)
@@ -14,7 +16,7 @@ struct PromptToCodeSettingsView: View {
var promptToCodeChatModelId
@AppStorage(\.promptToCodeEmbeddingModelId)
var promptToCodeEmbeddingModelId
-
+ @AppStorage(\.wrapCodeInPromptToCode) var wrapCode
@AppStorage(\.chatModels) var chatModels
@AppStorage(\.embeddingModels) var embeddingModels
init() {}
@@ -26,18 +28,18 @@ struct PromptToCodeSettingsView: View {
VStack(alignment: .center) {
Form {
Picker(
- "Chat Model",
+ "Chat model",
selection: $settings.promptToCodeChatModelId
) {
- Text("Same as Chat Feature").tag("")
-
+ Text("Same as chat feature").tag("")
+
if !settings.chatModels
.contains(where: { $0.id == settings.promptToCodeChatModelId }),
!settings.promptToCodeChatModelId.isEmpty
{
Text(
(settings.chatModels.first?.name).map { "\($0) (Default)" }
- ?? "No Model Found"
+ ?? "No model found"
)
.tag(settings.promptToCodeChatModelId)
}
@@ -46,74 +48,31 @@ struct PromptToCodeSettingsView: View {
Text(chatModel.name).tag(chatModel.id)
}
}
+ }
- Picker(
- "Embedding Model",
- selection: $settings.promptToCodeEmbeddingModelId
- ) {
- Text("Same as Chat Feature").tag("")
-
- if !settings.embeddingModels
- .contains(where: { $0.id == settings.promptToCodeEmbeddingModelId }),
- !settings.promptToCodeEmbeddingModelId.isEmpty
- {
- Text(
- (settings.embeddingModels.first?.name).map { "\($0) (Default)" }
- ?? "No Model Found"
- )
- .tag(settings.promptToCodeEmbeddingModelId)
- }
+ SettingsDivider("UI")
- ForEach(settings.embeddingModels, id: \.id) { embeddingModel in
- Text(embeddingModel.name).tag(embeddingModel.id)
- }
- }
-
- Toggle(isOn: $settings.promptToCodeGenerateDescription) {
- Text("Generate Description")
+ Form {
+ Toggle(isOn: $settings.hideCommonPrecedingSpaces) {
+ Text("Hide common preceding spaces")
}
- Toggle(isOn: $settings.promptToCodeGenerateDescriptionInUserPreferredLanguage) {
- Text("Generate Description in user preferred language")
+ Toggle(isOn: $settings.wrapCode) {
+ Text("Wrap code")
}
- }
-
- Divider()
-
- Text("Mirroring Settings of Suggestion Feature")
- .foregroundColor(.white)
- .padding(.vertical, 2)
- .padding(.horizontal, 8)
- .background(
- Color.accentColor,
- in: RoundedRectangle(cornerRadius: 20)
- )
- Form {
- Toggle(isOn: $settings.hideCommonPrecedingSpacesInSuggestion) {
- Text("Hide Common Preceding Spaces")
- }.disabled(true)
-
- HStack {
- TextField(text: .init(get: {
- "\(Int(settings.suggestionCodeFontSize))"
- }, set: {
- settings.suggestionCodeFontSize = Double(Int($0) ?? 0)
- })) {
- Text("Font size of suggestion code")
- }
- .textFieldStyle(.roundedBorder)
+ CodeHighlightThemePicker(scenario: .promptToCode)
- Text("pt")
- }.disabled(true)
+ FontPicker(font: $settings.font) {
+ Text("Font")
+ }
}
}
}
}
-struct PromptToCodeSettingsView_Previews: PreviewProvider {
- static var previews: some View {
- PromptToCodeSettingsView()
- }
+#Preview {
+ PromptToCodeSettingsView()
+ .padding()
}
diff --git a/Core/Sources/HostApp/FeatureSettings/SuggestionFeatureDisabledLanguageListView.swift b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureDisabledLanguageListView.swift
similarity index 89%
rename from Core/Sources/HostApp/FeatureSettings/SuggestionFeatureDisabledLanguageListView.swift
rename to Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureDisabledLanguageListView.swift
index e011751b..6d894cfd 100644
--- a/Core/Sources/HostApp/FeatureSettings/SuggestionFeatureDisabledLanguageListView.swift
+++ b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureDisabledLanguageListView.swift
@@ -1,4 +1,4 @@
-import SuggestionModel
+import SuggestionBasic
import SwiftUI
import SharedUIComponents
@@ -29,16 +29,8 @@ struct SuggestionFeatureDisabledLanguageListView: View {
.padding()
}
.buttonStyle(.plain)
- Text("Enabled Projects")
+ Text("Disabled Languages")
Spacer()
- Button(action: {
- isAddingNewProject = true
- }) {
- Image(systemName: "plus.circle.fill")
- .foregroundStyle(.secondary)
- .padding()
- }
- .buttonStyle(.plain)
}
.background(Color(nsColor: .separatorColor))
@@ -82,9 +74,10 @@ struct SuggestionFeatureDisabledLanguageListView: View {
if settings.suggestionFeatureDisabledLanguageList.isEmpty {
Text("""
Empty
- Disable the language of a file by right clicking the circular widget.
+ Disable the language of a file by right clicking the indicator widget.
""")
.multilineTextAlignment(.center)
+ .padding()
}
}
}
diff --git a/Core/Sources/HostApp/FeatureSettings/SuggestionFeatureEnabledProjectListView.swift b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureEnabledProjectListView.swift
similarity index 98%
rename from Core/Sources/HostApp/FeatureSettings/SuggestionFeatureEnabledProjectListView.swift
rename to Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureEnabledProjectListView.swift
index f57cd5e4..0cf66ca6 100644
--- a/Core/Sources/HostApp/FeatureSettings/SuggestionFeatureEnabledProjectListView.swift
+++ b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureEnabledProjectListView.swift
@@ -82,7 +82,7 @@ struct SuggestionFeatureEnabledProjectListView: View {
Text("""
Empty
Add project with "+" button
- Or right clicking the circular widget
+ Or right clicking the indicator widget
""")
.multilineTextAlignment(.center)
}
diff --git a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsGeneralSectionView.swift b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsGeneralSectionView.swift
new file mode 100644
index 00000000..390c7f98
--- /dev/null
+++ b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsGeneralSectionView.swift
@@ -0,0 +1,327 @@
+import Client
+import Preferences
+import SharedUIComponents
+import SwiftUI
+import XPCShared
+
+#if canImport(ProHostApp)
+import ProHostApp
+#endif
+
+struct SuggestionSettingsGeneralSectionView: View {
+ struct SuggestionFeatureProviderOption: Identifiable, Hashable {
+ var id: String {
+ (builtInProvider?.rawValue).map(String.init) ?? bundleIdentifier ?? "n/A"
+ }
+
+ var name: String
+ var builtInProvider: BuiltInSuggestionFeatureProvider?
+ var bundleIdentifier: String?
+
+ func hash(into hasher: inout Hasher) {
+ id.hash(into: &hasher)
+ }
+
+ init(
+ name: String,
+ builtInProvider: BuiltInSuggestionFeatureProvider? = nil,
+ bundleIdentifier: String? = nil
+ ) {
+ self.name = name
+ self.builtInProvider = builtInProvider
+ self.bundleIdentifier = bundleIdentifier
+ }
+ }
+
+ final class Settings: ObservableObject {
+ @AppStorage(\.realtimeSuggestionToggle)
+ var realtimeSuggestionToggle
+ @AppStorage(\.realtimeSuggestionDebounce)
+ var realtimeSuggestionDebounce
+ @AppStorage(\.suggestionPresentationMode)
+ var suggestionPresentationMode
+ @AppStorage(\.disableSuggestionFeatureGlobally)
+ var disableSuggestionFeatureGlobally
+ @AppStorage(\.suggestionFeatureEnabledProjectList)
+ var suggestionFeatureEnabledProjectList
+ @AppStorage(\.hideCommonPrecedingSpacesInSuggestion)
+ var hideCommonPrecedingSpacesInSuggestion
+ @AppStorage(\.suggestionCodeFont)
+ var font
+ @AppStorage(\.suggestionFeatureProvider)
+ var suggestionFeatureProvider
+ @AppStorage(\.suggestionDisplayCompactMode)
+ var suggestionDisplayCompactMode
+ @AppStorage(\.acceptSuggestionWithTab)
+ var acceptSuggestionWithTab
+ @AppStorage(\.dismissSuggestionWithEsc)
+ var dismissSuggestionWithEsc
+
+ var refreshExtensionSuggestionFeatureProvidersTask: Task?
+
+ @MainActor
+ @Published
+ var extensionSuggestionFeatureProviderOptions = [SuggestionFeatureProviderOption]()
+
+ init() {
+ Task { @MainActor in
+ refreshExtensionSuggestionFeatureProviders()
+ }
+ refreshExtensionSuggestionFeatureProvidersTask = Task { [weak self] in
+ let sequence = NotificationCenter.default
+ .notifications(named: NSApplication.didBecomeActiveNotification)
+ for await _ in sequence {
+ guard let self else { return }
+ await MainActor.run {
+ self.refreshExtensionSuggestionFeatureProviders()
+ }
+ }
+ }
+ }
+
+ @MainActor
+ func refreshExtensionSuggestionFeatureProviders() {
+ guard let service = try? getService() else { return }
+ Task { @MainActor in
+ let services = try await service
+ .send(requestBody: ExtensionServiceRequests.GetExtensionSuggestionServices())
+ extensionSuggestionFeatureProviderOptions = services.map {
+ .init(name: $0.name, bundleIdentifier: $0.bundleIdentifier)
+ }
+ }
+ }
+ }
+
+ @StateObject var settings = Settings()
+ @State var isSuggestionFeatureEnabledListPickerOpen = false
+ @State var isSuggestionFeatureDisabledLanguageListViewOpen = false
+ @State var isTabToAcceptSuggestionModifierViewOpen = false
+
+ var body: some View {
+ Form {
+ Picker(selection: $settings.suggestionPresentationMode) {
+ ForEach(PresentationMode.allCases, id: \.rawValue) {
+ switch $0 {
+ case .nearbyTextCursor:
+ Text("Nearby text cursor").tag($0)
+ case .floatingWidget:
+ Text("Floating widget").tag($0)
+ }
+ }
+ } label: {
+ Text("Presentation")
+ }
+
+ Picker(selection: Binding(get: {
+ switch settings.suggestionFeatureProvider {
+ case let .builtIn(provider):
+ return SuggestionFeatureProviderOption(
+ name: "",
+ builtInProvider: provider
+ )
+ case let .extension(name, identifier):
+ return SuggestionFeatureProviderOption(
+ name: name,
+ bundleIdentifier: identifier
+ )
+ }
+ }, set: { (option: SuggestionFeatureProviderOption) in
+ if let provider = option.builtInProvider {
+ settings.suggestionFeatureProvider = .builtIn(provider)
+ } else {
+ settings.suggestionFeatureProvider = .extension(
+ name: option.name,
+ bundleIdentifier: option.bundleIdentifier ?? ""
+ )
+ }
+ })) {
+ ForEach(BuiltInSuggestionFeatureProvider.allCases, id: \.rawValue) {
+ switch $0 {
+ case .gitHubCopilot:
+ Text("GitHub Copilot")
+ .tag(SuggestionFeatureProviderOption(name: "", builtInProvider: $0))
+ case .codeium:
+ Text("Codeium")
+ .tag(SuggestionFeatureProviderOption(name: "", builtInProvider: $0))
+ }
+ }
+
+ ForEach(settings.extensionSuggestionFeatureProviderOptions, id: \.self) { item in
+ Text(item.name).tag(item)
+ }
+
+ if case let .extension(name, identifier) = settings.suggestionFeatureProvider {
+ if !settings.extensionSuggestionFeatureProviderOptions.contains(where: {
+ $0.bundleIdentifier == identifier
+ }) {
+ Text("\(name) (Not found)").tag(
+ SuggestionFeatureProviderOption(
+ name: name,
+ bundleIdentifier: identifier
+ )
+ )
+ }
+ }
+ } label: {
+ Text("Feature provider")
+ }
+
+ Toggle(isOn: $settings.realtimeSuggestionToggle) {
+ Text("Real-time suggestion")
+ }
+
+ Toggle(isOn: $settings.acceptSuggestionWithTab) {
+ HStack {
+ Text("Accept suggestion with Tab")
+
+ Button(action: {
+ isTabToAcceptSuggestionModifierViewOpen = true
+ }) {
+ Image(systemName: "gearshape.fill")
+ }
+ .buttonStyle(.plain)
+ }
+ }.sheet(isPresented: $isTabToAcceptSuggestionModifierViewOpen) {
+ TabToAcceptSuggestionModifierView()
+ }
+
+ Toggle(isOn: $settings.dismissSuggestionWithEsc) {
+ Text("Dismiss suggestion with ESC")
+ }
+
+ HStack {
+ Toggle(isOn: $settings.disableSuggestionFeatureGlobally) {
+ Text("Disable suggestion feature globally")
+ }
+
+ Button("Exception list") {
+ isSuggestionFeatureEnabledListPickerOpen = true
+ }
+ }.sheet(isPresented: $isSuggestionFeatureEnabledListPickerOpen) {
+ SuggestionFeatureEnabledProjectListView(
+ isOpen: $isSuggestionFeatureEnabledListPickerOpen
+ )
+ }
+
+ HStack {
+ Button("Disabled language list") {
+ isSuggestionFeatureDisabledLanguageListViewOpen = true
+ }
+ }.sheet(isPresented: $isSuggestionFeatureDisabledLanguageListViewOpen) {
+ SuggestionFeatureDisabledLanguageListView(
+ isOpen: $isSuggestionFeatureDisabledLanguageListViewOpen
+ )
+ }
+
+ HStack {
+ Slider(value: $settings.realtimeSuggestionDebounce, in: 0.1...2, step: 0.1) {
+ Text("Real-time suggestion debounce")
+ }
+
+ Text(
+ "\(settings.realtimeSuggestionDebounce.formatted(.number.precision(.fractionLength(2))))s"
+ )
+ .font(.body)
+ .monospacedDigit()
+ .padding(.vertical, 2)
+ .padding(.horizontal, 6)
+ .background(
+ RoundedRectangle(cornerRadius: 4, style: .continuous)
+ .fill(Color.primary.opacity(0.1))
+ )
+ }
+ }
+
+ SettingsDivider("UI")
+
+ Form {
+ Toggle(isOn: $settings.suggestionDisplayCompactMode) {
+ Text("Hide buttons")
+ }
+
+ Toggle(isOn: $settings.hideCommonPrecedingSpacesInSuggestion) {
+ Text("Hide common preceding spaces")
+ }
+
+ CodeHighlightThemePicker(scenario: .suggestion)
+
+ FontPicker(font: $settings.font) {
+ Text("Font")
+ }
+ }
+ }
+
+ struct TabToAcceptSuggestionModifierView: View {
+ final class Settings: ObservableObject {
+ @AppStorage(\.acceptSuggestionWithModifierCommand)
+ var needCommand
+ @AppStorage(\.acceptSuggestionWithModifierOption)
+ var needOption
+ @AppStorage(\.acceptSuggestionWithModifierShift)
+ var needShift
+ @AppStorage(\.acceptSuggestionWithModifierControl)
+ var needControl
+ @AppStorage(\.acceptSuggestionWithModifierOnlyForSwift)
+ var onlyForSwift
+ @AppStorage(\.acceptSuggestionLineWithModifierControl)
+ var acceptLineWithControl
+ }
+
+ @StateObject var settings = Settings()
+ @Environment(\.dismiss) var dismiss
+
+ var body: some View {
+ VStack(spacing: 0) {
+ Form {
+ Text("Accept suggestion with modifier")
+ .font(.headline)
+ HStack {
+ Toggle(isOn: $settings.needCommand) {
+ Text("Command")
+ }
+ Toggle(isOn: $settings.needOption) {
+ Text("Option")
+ }
+ Toggle(isOn: $settings.needShift) {
+ Text("Shift")
+ }
+ Toggle(isOn: $settings.needControl) {
+ Text("Control")
+ }
+ }
+ Toggle(isOn: $settings.onlyForSwift) {
+ Text("Only require modifiers for Swift")
+ }
+
+ Divider()
+
+ Toggle(isOn: $settings.acceptLineWithControl) {
+ Text("Accept suggestion first line with Control")
+ }
+ }
+ .padding()
+
+ Divider()
+
+ HStack {
+ Spacer()
+ Button(action: { dismiss() }) {
+ Text("Done")
+ }
+ .keyboardShortcut(.defaultAction)
+ }
+ .padding()
+ }
+ }
+ }
+}
+
+#Preview {
+ SuggestionSettingsGeneralSectionView()
+ .padding()
+}
+
+#Preview {
+ SuggestionSettingsGeneralSectionView.TabToAcceptSuggestionModifierView()
+}
+
diff --git a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsView.swift b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsView.swift
new file mode 100644
index 00000000..632769a4
--- /dev/null
+++ b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsView.swift
@@ -0,0 +1,53 @@
+import Client
+import Preferences
+import SharedUIComponents
+import SwiftUI
+import XPCShared
+
+struct SuggestionSettingsView: View {
+ var tabContainer: ExternalTabContainer {
+ ExternalTabContainer.tabContainer(for: "SuggestionSettings")
+ }
+
+ enum Tab: Hashable {
+ case general
+ case other(String)
+ }
+
+ @State var tabSelection: Tab = .general
+
+ var body: some View {
+ VStack(spacing: 0) {
+ Picker("", selection: $tabSelection) {
+ Text("General").tag(Tab.general)
+ ForEach(tabContainer.tabs, id: \.id) { tab in
+ Text(tab.title).tag(Tab.other(tab.id))
+ }
+ }
+ .pickerStyle(.segmented)
+ .padding(8)
+
+ Divider()
+ .shadow(radius: 10)
+
+ ScrollView {
+ Group {
+ switch tabSelection {
+ case .general:
+ SuggestionSettingsGeneralSectionView()
+ case let .other(id):
+ tabContainer.tabView(for: id)
+ }
+ }.padding()
+ }
+ }
+ }
+}
+
+struct SuggestionSettingsView_Previews: PreviewProvider {
+ static var previews: some View {
+ SuggestionSettingsView()
+ .frame(width: 600, height: 500)
+ }
+}
+
diff --git a/Core/Sources/HostApp/FeatureSettings/SuggestionSettingsView.swift b/Core/Sources/HostApp/FeatureSettings/SuggestionSettingsView.swift
deleted file mode 100644
index 8218f95a..00000000
--- a/Core/Sources/HostApp/FeatureSettings/SuggestionSettingsView.swift
+++ /dev/null
@@ -1,155 +0,0 @@
-import Preferences
-import SwiftUI
-
-#if canImport(ProHostApp)
-import ProHostApp
-#endif
-
-struct SuggestionSettingsView: View {
- final class Settings: ObservableObject {
- @AppStorage(\.realtimeSuggestionToggle)
- var realtimeSuggestionToggle
- @AppStorage(\.realtimeSuggestionDebounce)
- var realtimeSuggestionDebounce
- @AppStorage(\.suggestionPresentationMode)
- var suggestionPresentationMode
- @AppStorage(\.disableSuggestionFeatureGlobally)
- var disableSuggestionFeatureGlobally
- @AppStorage(\.suggestionFeatureEnabledProjectList)
- var suggestionFeatureEnabledProjectList
- @AppStorage(\.hideCommonPrecedingSpacesInSuggestion)
- var hideCommonPrecedingSpacesInSuggestion
- @AppStorage(\.suggestionCodeFontSize)
- var suggestionCodeFontSize
- @AppStorage(\.suggestionFeatureProvider)
- var suggestionFeatureProvider
- @AppStorage(\.suggestionDisplayCompactMode)
- var suggestionDisplayCompactMode
- @AppStorage(\.acceptSuggestionWithTab)
- var acceptSuggestionWithTab
- init() {}
- }
-
- @StateObject var settings = Settings()
- @State var isSuggestionFeatureEnabledListPickerOpen = false
- @State var isSuggestionFeatureDisabledLanguageListViewOpen = false
-
- var body: some View {
- Form {
- Group {
- Picker(selection: $settings.suggestionPresentationMode) {
- ForEach(PresentationMode.allCases, id: \.rawValue) {
- switch $0 {
- case .nearbyTextCursor:
- Text("Nearby Text Cursor").tag($0)
- case .floatingWidget:
- Text("Floating Widget").tag($0)
- }
- }
- } label: {
- Text("Presentation")
- }
-
- Picker(selection: $settings.suggestionFeatureProvider) {
- ForEach(SuggestionFeatureProvider.allCases, id: \.rawValue) {
- switch $0 {
- case .gitHubCopilot:
- Text("GitHub Copilot").tag($0)
- case .codeium:
- Text("Codeium").tag($0)
- }
- }
- } label: {
- Text("Feature Provider")
- }
-
- Toggle(isOn: $settings.realtimeSuggestionToggle) {
- Text("Real-time Suggestion")
- }
-
- #if canImport(ProHostApp)
- WithFeatureEnabled(\.tabToAcceptSuggestion) {
- Toggle(isOn: $settings.acceptSuggestionWithTab) {
- Text("Accept Suggestion with Tab")
- }
- }
- #endif
-
- HStack {
- Toggle(isOn: $settings.disableSuggestionFeatureGlobally) {
- Text("Disable Suggestion Feature Globally")
- }
-
- Button("Exception List") {
- isSuggestionFeatureEnabledListPickerOpen = true
- }
- }.sheet(isPresented: $isSuggestionFeatureEnabledListPickerOpen) {
- SuggestionFeatureEnabledProjectListView(
- isOpen: $isSuggestionFeatureEnabledListPickerOpen
- )
- }
-
- HStack {
- Button("Disabled Language List") {
- isSuggestionFeatureDisabledLanguageListViewOpen = true
- }
- }.sheet(isPresented: $isSuggestionFeatureDisabledLanguageListViewOpen) {
- SuggestionFeatureDisabledLanguageListView(
- isOpen: $isSuggestionFeatureDisabledLanguageListViewOpen
- )
- }
-
- HStack {
- Slider(value: $settings.realtimeSuggestionDebounce, in: 0...2, step: 0.1) {
- Text("Real-time Suggestion Debounce")
- }
-
- Text(
- "\(settings.realtimeSuggestionDebounce.formatted(.number.precision(.fractionLength(2))))s"
- )
- .font(.body)
- .monospacedDigit()
- .padding(.vertical, 2)
- .padding(.horizontal, 6)
- .background(
- RoundedRectangle(cornerRadius: 4, style: .continuous)
- .fill(Color.primary.opacity(0.1))
- )
- }
-
- Divider()
- }
-
- Group {
- Toggle(isOn: $settings.suggestionDisplayCompactMode) {
- Text("Hide Buttons")
- }
-
- Toggle(isOn: $settings.hideCommonPrecedingSpacesInSuggestion) {
- Text("Hide Common Preceding Spaces")
- }
-
- HStack {
- TextField(text: .init(get: {
- "\(Int(settings.suggestionCodeFontSize))"
- }, set: {
- settings.suggestionCodeFontSize = Double(Int($0) ?? 0)
- })) {
- Text("Font size of suggestion code")
- }
- .textFieldStyle(.roundedBorder)
-
- Text("pt")
- }
- Divider()
- }
- }
- }
-}
-
-struct SuggestionSettingsView_Previews: PreviewProvider {
- static var previews: some View {
- SuggestionSettingsView()
- }
-}
-
diff --git a/Core/Sources/HostApp/FeatureSettings/TerminalSettingsView.swift b/Core/Sources/HostApp/FeatureSettings/TerminalSettingsView.swift
new file mode 100644
index 00000000..43a8a539
--- /dev/null
+++ b/Core/Sources/HostApp/FeatureSettings/TerminalSettingsView.swift
@@ -0,0 +1,27 @@
+import Preferences
+import SharedUIComponents
+import SwiftUI
+
+#if canImport(ProHostApp)
+import ProHostApp
+#endif
+
+struct TerminalSettingsView: View {
+ class Settings: ObservableObject {
+ @AppStorage(\.terminalFont) var terminalFont
+ init() {}
+ }
+
+ @StateObject var settings = Settings()
+
+ var body: some View {
+ ScrollView {
+ Form {
+ FontPicker(font: $settings.terminalFont) {
+ Text("Font of code")
+ }
+ }
+ }
+
+ }
+}
diff --git a/Core/Sources/HostApp/FeatureSettings/XcodeSettingsView.swift b/Core/Sources/HostApp/FeatureSettings/XcodeSettingsView.swift
new file mode 100644
index 00000000..198aae19
--- /dev/null
+++ b/Core/Sources/HostApp/FeatureSettings/XcodeSettingsView.swift
@@ -0,0 +1,20 @@
+import Foundation
+import SharedUIComponents
+import SwiftUI
+
+#if canImport(ProHostApp)
+import ProHostApp
+#endif
+
+struct XcodeSettingsView: View {
+ var body: some View {
+ VStack {
+ #if canImport(ProHostApp)
+ CloseXcodeIdleTabsSettingsView()
+ #endif
+
+ EmptyView()
+ }
+ }
+}
+
diff --git a/Core/Sources/HostApp/FeatureSettingsView.swift b/Core/Sources/HostApp/FeatureSettingsView.swift
index cc8616c4..e8c1e38f 100644
--- a/Core/Sources/HostApp/FeatureSettingsView.swift
+++ b/Core/Sources/HostApp/FeatureSettingsView.swift
@@ -1,39 +1,62 @@
import SwiftUI
+import SharedUIComponents
struct FeatureSettingsView: View {
+ var tabContainer: ExternalTabContainer {
+ ExternalTabContainer.tabContainer(for: "Features")
+ }
+
@State var tag = 0
var body: some View {
- SidebarTabView(tag: $tag) {
- ScrollView {
- SuggestionSettingsView().padding()
- }
- .sidebarItem(
- tag: 0,
- title: "Suggestion",
- subtitle: "Generate suggestions for your code",
- image: "lightbulb"
- )
+ SidebarTabView(tag: $tag) {
+ SuggestionSettingsView()
+ .sidebarItem(
+ tag: 0,
+ title: "Suggestion",
+ subtitle: "Generate suggestions for your code",
+ image: "lightbulb"
+ )
+
+ ChatSettingsView()
+ .sidebarItem(
+ tag: 1,
+ title: "Chat",
+ subtitle: "Chat about your code",
+ image: "character.bubble"
+ )
ScrollView {
- ChatSettingsView().padding()
+ PromptToCodeSettingsView().padding()
}
.sidebarItem(
- tag: 1,
- title: "Chat",
- subtitle: "Chat about your code",
- image: "character.bubble"
+ tag: 2,
+ title: "Modification",
+ subtitle: "Write or modify code with natural language",
+ image: "paintbrush"
)
ScrollView {
- PromptToCodeSettingsView().padding()
+ XcodeSettingsView().padding()
}
.sidebarItem(
- tag: 2,
- title: "Prompt to Code",
- subtitle: "Write code with natural language",
- image: "paintbrush"
+ tag: 3,
+ title: "Xcode",
+ subtitle: "Xcode related features",
+ image: "hammer.circle"
)
+
+ ForEach(Array(tabContainer.tabs.enumerated()), id: \.1.id) { index, tab in
+ ScrollView {
+ tab.viewBuilder().padding()
+ }
+ .sidebarItem(
+ tag: 4 + index,
+ title: tab.title,
+ subtitle: tab.description,
+ image: tab.image
+ )
+ }
}
}
}
@@ -44,4 +67,3 @@ struct FeatureSettingsView_Previews: PreviewProvider {
.frame(width: 800)
}
}
-
diff --git a/Core/Sources/HostApp/General.swift b/Core/Sources/HostApp/General.swift
index 6e1aa17e..96ade16c 100644
--- a/Core/Sources/HostApp/General.swift
+++ b/Core/Sources/HostApp/General.swift
@@ -3,64 +3,247 @@ import ComposableArchitecture
import Foundation
import LaunchAgentManager
import SwiftUI
+import XPCShared
-struct General: ReducerProtocol {
+@Reducer
+struct General {
+ @ObservableState
struct State: Equatable {
var xpcServiceVersion: String?
var isAccessibilityPermissionGranted: Bool?
var isReloading = false
+ @Presents var alert: AlertState?
}
- enum Action: Equatable {
+ enum Action {
case appear
case setupLaunchAgentIfNeeded
+ case setupLaunchAgentClicked
+ case removeLaunchAgentClicked
+ case reloadLaunchAgentClicked
+ case openExtensionManager
case reloadStatus
case finishReloading(xpcServiceVersion: String, permissionGranted: Bool)
case failedReloading
+ case alert(PresentationAction)
+
+ case setupLaunchAgent
+ case finishSetupLaunchAgent
+ case finishRemoveLaunchAgent
+ case finishReloadLaunchAgent
+
+ @CasePathable
+ enum Alert: Equatable {
+ case moveToApplications
+ case moveTo(URL)
+ case install
+ }
}
@Dependency(\.toast) var toast
- var body: some ReducerProtocol {
+ struct ReloadStatusCancellableId: Hashable {}
+
+ static var didWarnInstallationPosition: Bool {
+ get { UserDefaults.standard.bool(forKey: "didWarnInstallationPosition") }
+ set { UserDefaults.standard.set(newValue, forKey: "didWarnInstallationPosition") }
+ }
+
+ static var bundleIsInApplicationsFolder: Bool {
+ Bundle.main.bundleURL.path.hasPrefix("/Applications")
+ }
+
+ var body: some ReducerOf {
Reduce { state, action in
switch action {
case .appear:
- return .run { send in
- await send(.setupLaunchAgentIfNeeded)
+ if Self.bundleIsInApplicationsFolder {
+ return .run { send in
+ await send(.setupLaunchAgentIfNeeded)
+ }
}
+
+ if !Self.didWarnInstallationPosition {
+ Self.didWarnInstallationPosition = true
+ state.alert = .init {
+ TextState("Move to Applications Folder?")
+ } actions: {
+ ButtonState(action: .moveToApplications) {
+ TextState("Move")
+ }
+ ButtonState(role: .cancel) {
+ TextState("Not Now")
+ }
+ } message: {
+ TextState(
+ "To ensure the best experience, please move the app to the Applications folder. If the app is not inside the Applications folder, please set up the launch agent manually by clicking the button."
+ )
+ }
+ }
+
+ return .none
+
case .setupLaunchAgentIfNeeded:
return .run { send in
#if DEBUG
// do not auto install on debug build
#else
- Task {
- do {
- try await LaunchAgentManager()
- .setupLaunchAgentForTheFirstTimeIfNeeded()
- } catch {
- toast(error.localizedDescription, .error)
- }
+ do {
+ try await LaunchAgentManager()
+ .setupLaunchAgentForTheFirstTimeIfNeeded()
+ } catch {
+ toast(error.localizedDescription, .error)
}
#endif
await send(.reloadStatus)
}
+
+ case .setupLaunchAgentClicked:
+ if Self.bundleIsInApplicationsFolder {
+ return .run { send in
+ await send(.setupLaunchAgent)
+ }
+ }
+
+ state.alert = .init {
+ TextState("Setup Launch Agent")
+ } actions: {
+ ButtonState(action: .install) {
+ TextState("Setup")
+ }
+
+ ButtonState(action: .moveToApplications) {
+ TextState("Move to Applications Folder")
+ }
+
+ ButtonState(role: .cancel) {
+ TextState("Cancel")
+ }
+ } message: {
+ TextState(
+ "It's recommended to move the app into the Applications folder. But you can still keep it in the current folder and install the launch agent to ~/Library/LaunchAgents."
+ )
+ }
+
+ return .none
+
+ case .removeLaunchAgentClicked:
+ return .run { send in
+ do {
+ try await LaunchAgentManager().removeLaunchAgent()
+ await send(.finishRemoveLaunchAgent)
+ } catch {
+ toast(error.localizedDescription, .error)
+ }
+ await send(.reloadStatus)
+ }
+
+ case .reloadLaunchAgentClicked:
+ return .run { send in
+ do {
+ try await LaunchAgentManager().reloadLaunchAgent()
+ await send(.finishReloadLaunchAgent)
+ } catch {
+ toast(error.localizedDescription, .error)
+ }
+ await send(.reloadStatus)
+ }
+
+ case .setupLaunchAgent:
+ return .run { send in
+ do {
+ try await LaunchAgentManager().setupLaunchAgent()
+ await send(.finishSetupLaunchAgent)
+ } catch {
+ toast(error.localizedDescription, .error)
+ }
+ await send(.reloadStatus)
+ }
+
+ case .finishSetupLaunchAgent:
+ state.alert = .init {
+ TextState("Launch Agent Installed")
+ } actions: {
+ ButtonState {
+ TextState("OK")
+ }
+ } message: {
+ TextState(
+ "The launch agent has been installed. Please restart the app."
+ )
+ }
+ return .none
+
+ case .finishRemoveLaunchAgent:
+ state.alert = .init {
+ TextState("Launch Agent Removed")
+ } actions: {
+ ButtonState {
+ TextState("OK")
+ }
+ } message: {
+ TextState(
+ "The launch agent has been removed."
+ )
+ }
+ return .none
+
+ case .finishReloadLaunchAgent:
+ state.alert = .init {
+ TextState("Launch Agent Reloaded")
+ } actions: {
+ ButtonState {
+ TextState("OK")
+ }
+ } message: {
+ TextState(
+ "The launch agent has been reloaded."
+ )
+ }
+ return .none
+
+ case .openExtensionManager:
+ return .run { send in
+ let service = try getService()
+ do {
+ _ = try await service
+ .send(requestBody: ExtensionServiceRequests.OpenExtensionManager())
+ } catch {
+ toast(error.localizedDescription, .error)
+ await send(.failedReloading)
+ }
+ }
+
case .reloadStatus:
state.isReloading = true
return .run { send in
let service = try getService()
do {
- let xpcServiceVersion = try await service.getXPCServiceVersion().version
- let isAccessibilityPermissionGranted = try await service
- .getXPCServiceAccessibilityPermission()
- await send(.finishReloading(
- xpcServiceVersion: xpcServiceVersion,
- permissionGranted: isAccessibilityPermissionGranted
- ))
+ let isCommunicationReady = try await service.launchIfNeeded()
+ if isCommunicationReady {
+ let xpcServiceVersion = try await service.getXPCServiceVersion().version
+ let isAccessibilityPermissionGranted = try await service
+ .getXPCServiceAccessibilityPermission()
+ await send(.finishReloading(
+ xpcServiceVersion: xpcServiceVersion,
+ permissionGranted: isAccessibilityPermissionGranted
+ ))
+ } else {
+ toast("Launching service app.", .info)
+ try await Task.sleep(nanoseconds: 5_000_000_000)
+ await send(.reloadStatus)
+ }
+ } catch let error as XPCCommunicationBridgeError {
+ toast(
+ "Failed to reach communication bridge. \(error.localizedDescription)",
+ .error
+ )
+ await send(.failedReloading)
} catch {
toast(error.localizedDescription, .error)
await send(.failedReloading)
}
- }
+ }.cancellable(id: ReloadStatusCancellableId(), cancelInFlight: true)
case let .finishReloading(version, granted):
state.xpcServiceVersion = version
@@ -71,6 +254,38 @@ struct General: ReducerProtocol {
case .failedReloading:
state.isReloading = false
return .none
+
+ case let .alert(.presented(action)):
+ switch action {
+ case .moveToApplications:
+ return .run { send in
+ let appURL = URL(fileURLWithPath: "/Applications")
+ await send(.alert(.presented(.moveTo(appURL))))
+ }
+
+ case let .moveTo(url):
+ return .run { _ in
+ do {
+ try FileManager.default.moveItem(
+ at: Bundle.main.bundleURL,
+ to: url.appendingPathComponent(
+ Bundle.main.bundleURL.lastPathComponent
+ )
+ )
+ await NSApplication.shared.terminate(nil)
+ } catch {
+ toast(error.localizedDescription, .error)
+ }
+ }
+ case .install:
+ return .run { send in
+ await send(.setupLaunchAgent)
+ }
+ }
+
+ case .alert(.dismiss):
+ state.alert = nil
+ return .none
}
}
}
diff --git a/Core/Sources/HostApp/GeneralView.swift b/Core/Sources/HostApp/GeneralView.swift
index c011de4a..b69c0127 100644
--- a/Core/Sources/HostApp/GeneralView.swift
+++ b/Core/Sources/HostApp/GeneralView.swift
@@ -3,6 +3,7 @@ import ComposableArchitecture
import KeyboardShortcuts
import LaunchAgentManager
import Preferences
+import SharedUIComponents
import SwiftUI
struct GeneralView: View {
@@ -11,12 +12,12 @@ struct GeneralView: View {
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
- AppInfoView()
- Divider()
+ AppInfoView(store: store)
+ SettingsDivider()
ExtensionServiceView(store: store)
- Divider()
- LaunchAgentView()
- Divider()
+ SettingsDivider()
+ LaunchAgentView(store: store)
+ SettingsDivider()
GeneralSettingsView()
}
}
@@ -29,110 +30,121 @@ struct GeneralView: View {
struct AppInfoView: View {
@State var appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
@Environment(\.updateChecker) var updateChecker
+ @Perception.Bindable var store: StoreOf
var body: some View {
- VStack(alignment: .leading) {
- HStack(alignment: .top) {
- Text(
- Bundle.main
- .object(forInfoDictionaryKey: "HOST_APP_NAME") as? String
- ?? "Copilot for Xcode"
- )
- .font(.title)
- Text(appVersion ?? "")
- .font(.footnote)
- .foregroundColor(.secondary)
-
- Spacer()
+ WithPerceptionTracking {
+ VStack(alignment: .leading) {
+ HStack(alignment: .top) {
+ Text(
+ Bundle.main
+ .object(forInfoDictionaryKey: "HOST_APP_NAME") as? String
+ ?? "Copilot for Xcode"
+ )
+ .font(.title)
+ Text(appVersion ?? "")
+ .font(.footnote)
+ .foregroundColor(.secondary)
+
+ Spacer()
+
+ Button(action: {
+ store.send(.openExtensionManager)
+ }) {
+ HStack(spacing: 2) {
+ Image(systemName: "puzzlepiece.extension.fill")
+ Text("Extensions")
+ }
+ }
- Button(action: {
- updateChecker.checkForUpdates()
- }) {
- HStack(spacing: 2) {
- Image(systemName: "arrow.up.right.circle.fill")
- Text("Check for Updates")
+ Button(action: {
+ updateChecker.checkForUpdates()
+ }) {
+ HStack(spacing: 2) {
+ Image(systemName: "arrow.up.right.circle.fill")
+ Text("Check for Updates")
+ }
}
}
- }
- HStack(spacing: 16) {
- Link(
- destination: URL(string: "https://github.com/intitni/CopilotForXcode")!
- ) {
- HStack(spacing: 2) {
- Image(systemName: "link")
- Text("GitHub")
+ HStack(spacing: 16) {
+ Link(
+ destination: URL(string: "https://github.com/intitni/CopilotForXcode")!
+ ) {
+ HStack(spacing: 2) {
+ Image(systemName: "link")
+ Text("GitHub")
+ }
}
- }
- .focusable(false)
- .foregroundColor(.accentColor)
+ .focusable(false)
+ .foregroundColor(.accentColor)
- Link(destination: URL(string: "https://www.buymeacoffee.com/intitni")!) {
- HStack(spacing: 2) {
- Image(systemName: "cup.and.saucer.fill")
- Text("Buy Me A Coffee")
+ Link(destination: URL(string: "https://www.buymeacoffee.com/intitni")!) {
+ HStack(spacing: 2) {
+ Image(systemName: "cup.and.saucer.fill")
+ Text("Buy Me A Coffee")
+ }
}
+ .foregroundColor(.accentColor)
+ .focusable(false)
}
- .foregroundColor(.accentColor)
- .focusable(false)
}
- }.padding()
+ .padding()
+ .alert($store.scope(state: \.alert, action: \.alert))
+ }
}
}
struct ExtensionServiceView: View {
- let store: StoreOf
+ @Perception.Bindable var store: StoreOf
var body: some View {
- VStack(alignment: .leading) {
- WithViewStore(store, observe: { $0.xpcServiceVersion }) { viewStore in
- Text("Extension Service Version: \(viewStore.state ?? "Loading..")")
- }
+ WithPerceptionTracking {
+ VStack(alignment: .leading) {
+ Text("Extension Service Version: \(store.xpcServiceVersion ?? "Loading..")")
- WithViewStore(store, observe: { $0.isAccessibilityPermissionGranted }) { viewStore in
let grantedStatus: String = {
- guard let granted = viewStore.state else { return "Loading.." }
+ guard let granted = store.isAccessibilityPermissionGranted
+ else { return "Loading.." }
return granted ? "Granted" : "Not Granted"
}()
Text("Accessibility Permission: \(grantedStatus)")
- }
- HStack {
- WithViewStore(store, observe: { $0.isReloading }) { viewStore in
- Button(action: { viewStore.send(.reloadStatus) }) {
+ HStack {
+ Button(action: { store.send(.reloadStatus) }) {
Text("Refresh")
- }.disabled(viewStore.state)
- }
-
- Button(action: {
- Task {
- let workspace = NSWorkspace.shared
- let url = Bundle.main.bundleURL
- .appendingPathComponent("Contents")
- .appendingPathComponent("Applications")
- .appendingPathComponent("CopilotForXcodeExtensionService.app")
- workspace.activateFileViewerSelecting([url])
+ }.disabled(store.isReloading)
+
+ Button(action: {
+ Task {
+ let workspace = NSWorkspace.shared
+ let url = Bundle.main.bundleURL
+ .appendingPathComponent("Contents")
+ .appendingPathComponent("Applications")
+ .appendingPathComponent("CopilotForXcodeExtensionService.app")
+ workspace.activateFileViewerSelecting([url])
+ }
+ }) {
+ Text("Reveal Extension Service in Finder")
}
- }) {
- Text("Reveal Extension Service in Finder")
- }
- Button(action: {
- let url = URL(
- string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"
- )!
- NSWorkspace.shared.open(url)
- }) {
- Text("Accessibility Settings")
- }
+ Button(action: {
+ let url = URL(
+ string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"
+ )!
+ NSWorkspace.shared.open(url)
+ }) {
+ Text("Accessibility Settings")
+ }
- Button(action: {
- let url = URL(
- string: "x-apple.systempreferences:com.apple.ExtensionsPreferences"
- )!
- NSWorkspace.shared.open(url)
- }) {
- Text("Extensions Settings")
+ Button(action: {
+ let url = URL(
+ string: "x-apple.systempreferences:com.apple.ExtensionsPreferences"
+ )!
+ NSWorkspace.shared.open(url)
+ }) {
+ Text("Extensions Settings")
+ }
}
}
}
@@ -141,75 +153,34 @@ struct ExtensionServiceView: View {
}
struct LaunchAgentView: View {
+ @Perception.Bindable var store: StoreOf
@Environment(\.toast) var toast
- @State var isDidRemoveLaunchAgentAlertPresented = false
- @State var isDidSetupLaunchAgentAlertPresented = false
- @State var isDidRestartLaunchAgentAlertPresented = false
var body: some View {
- VStack(alignment: .leading) {
- HStack {
- Button(action: {
- Task {
- do {
- try await LaunchAgentManager().setupLaunchAgent()
- isDidSetupLaunchAgentAlertPresented = true
- } catch {
- toast(error.localizedDescription, .error)
- }
+ WithPerceptionTracking {
+ VStack(alignment: .leading) {
+ HStack {
+ Button(action: {
+ store.send(.setupLaunchAgentClicked)
+ }) {
+ Text("Setup Launch Agent")
}
- }) {
- Text("Set Up Launch Agent")
- }
- .alert(isPresented: $isDidSetupLaunchAgentAlertPresented) {
- .init(
- title: Text("Finished Launch Agent Setup"),
- message: Text(
- "Please refresh the Copilot status. (The first refresh may fail)"
- ),
- dismissButton: .default(Text("OK"))
- )
- }
- Button(action: {
- Task {
- do {
- try await LaunchAgentManager().removeLaunchAgent()
- isDidRemoveLaunchAgentAlertPresented = true
- } catch {
- toast(error.localizedDescription, .error)
- }
+ Button(action: {
+ store.send(.removeLaunchAgentClicked)
+ }) {
+ Text("Remove Launch Agent")
}
- }) {
- Text("Remove Launch Agent")
- }
- .alert(isPresented: $isDidRemoveLaunchAgentAlertPresented) {
- .init(
- title: Text("Launch Agent Removed"),
- dismissButton: .default(Text("OK"))
- )
- }
- Button(action: {
- Task {
- do {
- try await LaunchAgentManager().reloadLaunchAgent()
- isDidRestartLaunchAgentAlertPresented = true
- } catch {
- toast(error.localizedDescription, .error)
- }
+ Button(action: {
+ store.send(.reloadLaunchAgentClicked)
+ }) {
+ Text("Reload Launch Agent")
}
- }) {
- Text("Reload Launch Agent")
- }.alert(isPresented: $isDidRestartLaunchAgentAlertPresented) {
- .init(
- title: Text("Launch Agent Reloaded"),
- dismissButton: .default(Text("OK"))
- )
}
}
+ .padding()
}
- .padding()
}
}
@@ -227,10 +198,13 @@ struct GeneralSettingsView: View {
var hideCircularWidget
@AppStorage(\.showHideWidgetShortcutGlobally)
var showHideWidgetShortcutGlobally
+ @AppStorage(\.installBetaBuilds)
+ var installBetaBuilds
}
@StateObject var settings = Settings()
@Environment(\.updateChecker) var updateChecker
+ @State var automaticallyCheckForUpdate: Bool?
var body: some View {
Form {
@@ -239,12 +213,19 @@ struct GeneralSettingsView: View {
}
Toggle(isOn: .init(
- get: { updateChecker.automaticallyChecksForUpdates },
- set: { updateChecker.automaticallyChecksForUpdates = $0 }
+ get: { automaticallyCheckForUpdate ?? updateChecker.automaticallyChecksForUpdates },
+ set: {
+ updateChecker.automaticallyChecksForUpdates = $0
+ automaticallyCheckForUpdate = $0
+ }
)) {
Text("Automatically Check for Update")
}
+ Toggle(isOn: $settings.installBetaBuilds) {
+ Text("Install beta builds")
+ }
+
Picker(selection: $settings.suggestionWidgetPositionMode) {
ForEach(SuggestionWidgetPositionMode.allCases, id: \.rawValue) {
switch $0 {
@@ -299,7 +280,7 @@ struct GeneralSettingsView: View {
}
Toggle(isOn: $settings.hideCircularWidget) {
- Text("Hide circular widget")
+ Text("Hide indicator widget")
}
}.padding()
}
@@ -373,7 +354,7 @@ struct LargeIconPicker<
struct GeneralView_Previews: PreviewProvider {
static var previews: some View {
- GeneralView(store: .init(initialState: .init(), reducer: General()))
+ GeneralView(store: .init(initialState: .init(), reducer: { General() }))
.frame(height: 800)
}
}
diff --git a/Core/Sources/HostApp/HandleToast.swift b/Core/Sources/HostApp/HandleToast.swift
new file mode 100644
index 00000000..564fdada
--- /dev/null
+++ b/Core/Sources/HostApp/HandleToast.swift
@@ -0,0 +1,49 @@
+import Dependencies
+import SwiftUI
+import Toast
+
+struct ToastHandler: View {
+ @ObservedObject var toastController: ToastController
+ let namespace: String?
+
+ init(toastController: ToastController, namespace: String?) {
+ _toastController = .init(wrappedValue: toastController)
+ self.namespace = namespace
+ }
+
+ var body: some View {
+ VStack(spacing: 4) {
+ ForEach(toastController.messages) { message in
+ if let n = message.namespace, n != namespace {
+ EmptyView()
+ } else {
+ message.content
+ .foregroundColor(.white)
+ .padding(8)
+ .background({
+ switch message.type {
+ case .info: return Color.accentColor
+ case .error: return Color(nsColor: .systemRed)
+ case .warning: return Color(nsColor: .systemOrange)
+ }
+ }() as Color, in: RoundedRectangle(cornerRadius: 8))
+ .shadow(color: Color.black.opacity(0.2), radius: 4)
+ }
+ }
+ }
+ .padding()
+ .allowsHitTesting(false)
+ }
+}
+
+extension View {
+ func handleToast(namespace: String? = nil) -> some View {
+ @Dependency(\.toastController) var toastController
+ return overlay(alignment: .bottom) {
+ ToastHandler(toastController: toastController, namespace: namespace)
+ }.environment(\.toast) { [toastController] content, type in
+ toastController.toast(content: content, type: type, namespace: namespace)
+ }
+ }
+}
+
diff --git a/Core/Sources/HostApp/HostApp.swift b/Core/Sources/HostApp/HostApp.swift
index e5379319..f2b90303 100644
--- a/Core/Sources/HostApp/HostApp.swift
+++ b/Core/Sources/HostApp/HostApp.swift
@@ -4,26 +4,29 @@ import Foundation
import KeyboardShortcuts
#if canImport(LicenseManagement)
-import LicenseManagement
+import ProHostApp
#endif
extension KeyboardShortcuts.Name {
static let showHideWidget = Self("ShowHideWidget")
}
-struct HostApp: ReducerProtocol {
+@Reducer
+struct HostApp {
+ @ObservableState
struct State: Equatable {
var general = General.State()
var chatModelManagement = ChatModelManagement.State()
var embeddingModelManagement = EmbeddingModelManagement.State()
+ var webSearchSettings = WebSearchSettings.State()
}
- enum Action: Equatable {
+ enum Action {
case appear
- case informExtensionServiceAboutLicenseKeyChange
case general(General.Action)
case chatModelManagement(ChatModelManagement.Action)
case embeddingModelManagement(EmbeddingModelManagement.Action)
+ case webSearchSettings(WebSearchSettings.Action)
}
@Dependency(\.toast) var toast
@@ -32,38 +35,30 @@ struct HostApp: ReducerProtocol {
KeyboardShortcuts.userDefaults = .shared
}
- var body: some ReducerProtocol {
- Scope(state: \.general, action: /Action.general) {
+ var body: some ReducerOf {
+ Scope(state: \.general, action: \.general) {
General()
}
- Scope(state: \.chatModelManagement, action: /Action.chatModelManagement) {
+ Scope(state: \.chatModelManagement, action: \.chatModelManagement) {
ChatModelManagement()
}
- Scope(state: \.embeddingModelManagement, action: /Action.embeddingModelManagement) {
+ Scope(state: \.embeddingModelManagement, action: \.embeddingModelManagement) {
EmbeddingModelManagement()
}
+
+ Scope(state: \.webSearchSettings, action: \.webSearchSettings) {
+ WebSearchSettings()
+ }
Reduce { _, action in
switch action {
case .appear:
- return .none
-
- case .informExtensionServiceAboutLicenseKeyChange:
- #if canImport(LicenseManagement)
- return .run { _ in
- let service = try getService()
- do {
- try await service
- .postNotification(name: Notification.Name.licenseKeyChanged.rawValue)
- } catch {
- toast(error.localizedDescription, .error)
- }
- }
- #else
- return .none
+ #if canImport(ProHostApp)
+ ProHostApp.start()
#endif
+ return .none
case .general:
return .none
@@ -73,6 +68,9 @@ struct HostApp: ReducerProtocol {
case .embeddingModelManagement:
return .none
+
+ case .webSearchSettings:
+ return .none
}
}
}
diff --git a/Core/Sources/HostApp/LaunchAgentManager.swift b/Core/Sources/HostApp/LaunchAgentManager.swift
index db4d48b9..44937bb1 100644
--- a/Core/Sources/HostApp/LaunchAgentManager.swift
+++ b/Core/Sources/HostApp/LaunchAgentManager.swift
@@ -6,16 +6,14 @@ extension LaunchAgentManager {
self.init(
serviceIdentifier: Bundle.main
.object(forInfoDictionaryKey: "BUNDLE_IDENTIFIER_BASE") as! String +
- ".ExtensionService",
- executablePath: Bundle.main.bundleURL
+ ".CommunicationBridge",
+ executableURL: Bundle.main.bundleURL
.appendingPathComponent("Contents")
.appendingPathComponent("Applications")
- .appendingPathComponent(
- "CopilotForXcodeExtensionService.app/Contents/MacOS/CopilotForXcodeExtensionService"
- )
- .path,
+ .appendingPathComponent("CommunicationBridge"),
bundleIdentifier: Bundle.main
.object(forInfoDictionaryKey: "BUNDLE_IDENTIFIER_BASE") as! String
)
}
}
+
diff --git a/Core/Sources/HostApp/ServiceView.swift b/Core/Sources/HostApp/ServiceView.swift
index b0e6bf59..bf81eb51 100644
--- a/Core/Sources/HostApp/ServiceView.swift
+++ b/Core/Sources/HostApp/ServiceView.swift
@@ -1,64 +1,79 @@
-import SwiftUI
import ComposableArchitecture
+import SwiftUI
struct ServiceView: View {
let store: StoreOf
@State var tag = 0
-
+
var body: some View {
- SidebarTabView(tag: $tag) {
- ScrollView {
- GitHubCopilotView().padding()
- }.sidebarItem(
- tag: 0,
- title: "GitHub Copilot",
- subtitle: "Suggestion",
- image: "globe"
- )
-
- ScrollView {
- CodeiumView().padding()
- }.sidebarItem(
- tag: 1,
- title: "Codeium",
- subtitle: "Suggestion",
- image: "globe"
- )
-
- ChatModelManagementView(store: store.scope(
- state: \.chatModelManagement,
- action: HostApp.Action.chatModelManagement
- )).sidebarItem(
- tag: 2,
- title: "Chat Models",
- subtitle: "Chat, Prompt to Code",
- image: "globe"
- )
-
- EmbeddingModelManagementView(store: store.scope(
- state: \.embeddingModelManagement,
- action: HostApp.Action.embeddingModelManagement
- )).sidebarItem(
- tag: 3,
- title: "Embedding Models",
- subtitle: "Chat, Prompt to Code",
- image: "globe"
- )
-
- ScrollView {
- BingSearchView().padding()
- }.sidebarItem(
- tag: 4,
- title: "Bing Search",
- subtitle: "Search Chat Plugin",
- image: "globe"
- )
+ WithPerceptionTracking {
+ SidebarTabView(tag: $tag) {
+ WithPerceptionTracking {
+ ScrollView {
+ GitHubCopilotView().padding()
+ }.sidebarItem(
+ tag: 0,
+ title: "GitHub Copilot",
+ subtitle: "Suggestion",
+ image: "globe"
+ )
+
+ ScrollView {
+ CodeiumView().padding()
+ }.sidebarItem(
+ tag: 1,
+ title: "Codeium",
+ subtitle: "Suggestion",
+ image: "globe"
+ )
+
+ ChatModelManagementView(store: store.scope(
+ state: \.chatModelManagement,
+ action: \.chatModelManagement
+ )).sidebarItem(
+ tag: 2,
+ title: "Chat Models",
+ subtitle: "Chat, Modification",
+ image: "globe"
+ )
+
+ EmbeddingModelManagementView(store: store.scope(
+ state: \.embeddingModelManagement,
+ action: \.embeddingModelManagement
+ )).sidebarItem(
+ tag: 3,
+ title: "Embedding Models",
+ subtitle: "Chat, Modification",
+ image: "globe"
+ )
+
+ WebSearchView(store: store.scope(
+ state: \.webSearchSettings,
+ action: \.webSearchSettings
+ )).sidebarItem(
+ tag: 4,
+ title: "Web Search",
+ subtitle: "Chat, Modification",
+ image: "globe"
+ )
+
+ ScrollView {
+ OtherSuggestionServicesView().padding()
+ }.sidebarItem(
+ tag: 5,
+ title: "Other Suggestion Services",
+ subtitle: "Suggestion",
+ image: "globe"
+ )
+ }
+ }
}
}
}
struct AccountView_Previews: PreviewProvider {
static var previews: some View {
- ServiceView(store: .init(initialState: .init(), reducer: HostApp()))
+ ServiceView(store: .init(initialState: .init(), reducer: { HostApp() }))
}
}
+
diff --git a/Core/Sources/HostApp/SharedComponents/CodeHighlightThemePicker.swift b/Core/Sources/HostApp/SharedComponents/CodeHighlightThemePicker.swift
new file mode 100644
index 00000000..1c7151af
--- /dev/null
+++ b/Core/Sources/HostApp/SharedComponents/CodeHighlightThemePicker.swift
@@ -0,0 +1,71 @@
+import Foundation
+import Preferences
+import SwiftUI
+
+public struct CodeHighlightThemePicker: View {
+ public enum Scenario {
+ case suggestion
+ case promptToCode
+ case chat
+ }
+
+ let scenario: Scenario
+
+ public init(scenario: Scenario) {
+ self.scenario = scenario
+ }
+
+ public var body: some View {
+ switch scenario {
+ case .suggestion:
+ SuggestionThemePicker()
+ case .promptToCode:
+ PromptToCodeThemePicker()
+ case .chat:
+ ChatThemePicker()
+ }
+ }
+
+ struct SuggestionThemePicker: View {
+ @AppStorage(\.syncSuggestionHighlightTheme) var sync: Bool
+ var body: some View {
+ SyncToggle(sync: $sync)
+ }
+ }
+
+ struct PromptToCodeThemePicker: View {
+ @AppStorage(\.syncPromptToCodeHighlightTheme) var sync: Bool
+ var body: some View {
+ SyncToggle(sync: $sync)
+ }
+ }
+
+ struct ChatThemePicker: View {
+ @AppStorage(\.syncChatCodeHighlightTheme) var sync: Bool
+ var body: some View {
+ SyncToggle(sync: $sync)
+ }
+ }
+
+ struct SyncToggle: View {
+ @Binding var sync: Bool
+
+ var body: some View {
+ VStack(alignment: .leading) {
+ Toggle(isOn: $sync) {
+ Text("Sync color scheme with Xcode")
+ }
+
+ Text("To refresh the theme, you must activate the extension service app once.")
+ .font(.footnote)
+ .foregroundColor(.secondary)
+ }
+ }
+ }
+}
+
+#Preview {
+ CodeHighlightThemePicker.SyncToggle(sync: .constant(true))
+ CodeHighlightThemePicker.SyncToggle(sync: .constant(false))
+}
+
diff --git a/Core/Sources/HostApp/TabContainer.swift b/Core/Sources/HostApp/TabContainer.swift
index 02083499..8616b5af 100644
--- a/Core/Sources/HostApp/TabContainer.swift
+++ b/Core/Sources/HostApp/TabContainer.swift
@@ -2,16 +2,13 @@ import ComposableArchitecture
import Dependencies
import Foundation
import LaunchAgentManager
+import SharedUIComponents
import SwiftUI
import Toast
import UpdateChecker
-#if canImport(ProHostApp)
-import ProHostApp
-#endif
-
@MainActor
-let hostAppStore: StoreOf = .init(initialState: .init(), reducer: HostApp())
+let hostAppStore: StoreOf