diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 00000000..64e61f60
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1 @@
+custom: ["https://intii.lemonsqueezy.com", "https://www.buymeacoffee.com/intitni"]
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml
new file mode 100644
index 00000000..86a41f25
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.yaml
@@ -0,0 +1,56 @@
+name: Bug Report
+description: File a bug report
+title: "[Bug]: "
+labels: ["bug"]
+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. 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
+ - 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/ISSUE_TEMPLATE/feature_reqeust.yaml b/.github/ISSUE_TEMPLATE/feature_reqeust.yaml
new file mode 100644
index 00000000..0c034eed
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_reqeust.yaml
@@ -0,0 +1,28 @@
+name: Feature Request
+description: Request a feature
+title: "[Enhancement]: "
+labels: ["enhancement"]
+assignees:
+ - intitni
+body:
+ - type: markdown
+ attributes:
+ value: |
+ 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:
+ label: Before Requesting
+ description: Before requesting the feature, we suggestion that you first search for existing issues to avoid duplication.
+ options:
+ - label: I have searched the existing issues, and there is no existing issue for my feature request
+ required: true
+ - type: textarea
+ id: what-feature
+ attributes:
+ label: What feature do you want?
+ description: Please describe the feature you want.
+ placeholder: Tell us what you want!
+ value: "I want a feature!"
+ validations:
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/help_wanted.yml b/.github/ISSUE_TEMPLATE/help_wanted.yml
new file mode 100644
index 00000000..04b675c7
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/help_wanted.yml
@@ -0,0 +1,24 @@
+name: Help Wanted
+description: Ask for help from the developer and the community
+title: "[Help Wanted]: "
+labels: ["help wanted"]
+body:
+ - type: checkboxes
+ id: before-reporting
+ attributes:
+ label: Before Reporting
+ description: Before asking for help, 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-help
+ attributes:
+ label: Describe your issue
+ description: Please describe the help you want.
+ placeholder: My issue is...
+ value: "I want help!"
+ validations:
+ 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
new file mode 100644
index 00000000..1fae3c1f
--- /dev/null
+++ b/.github/workflows/close_inactive_issues.yml
@@ -0,0 +1,23 @@
+name: Close inactive issues
+on:
+ schedule:
+ - cron: "30 1 * * *"
+
+jobs:
+ close-issues:
+ runs-on: ubuntu-latest
+ permissions:
+ issues: write
+ pull-requests: write
+ steps:
+ - uses: actions/stale@v5
+ with:
+ days-before-issue-stale: 30
+ days-before-issue-close: 14
+ stale-issue-label: "stale"
+ 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
+ days-before-pr-close: -1
+ repo-token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.gitignore b/.gitignore
index ca98c1d5..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
@@ -123,3 +126,14 @@ iOSInjectionProject/
# End of
https://www.toptal.com/developers/gitignore/api/xcode,macos,swift,swiftpackagemanager
+
+Secrets.xcconfig
+Python/Python.xcframework
+Python/python-stdlib
+Python/site-packages/*
+!Python/site-packages/requirements.txt
+!Python/site-packages/install.sh
+
+Python/VERSIONS
+Copilot for Xcode Plus.xcworkspace
+PLUS
diff --git a/.gitmodules b/.gitmodules
index 418b5100..e69de29b 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,3 +0,0 @@
-[submodule "copilot.vim"]
- path = copilot.vim
- url = git@github.com:github/copilot.vim.git
diff --git a/AppIcon.png b/AppIcon.png
index 48f29e28..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.debug.xcconfig b/Config.debug.xcconfig
index 41826e24..5417881b 100644
--- a/Config.debug.xcconfig
+++ b/Config.debug.xcconfig
@@ -1,4 +1,13 @@
#include "Version.xcconfig"
+SLASH = /
+HOST_APP_NAME = Copilot for Xcode Dev
BUNDLE_IDENTIFIER_BASE = dev.com.intii.CopilotForXcode
+SPARKLE_FEED_URL = http:$(SLASH)$(SLASH)127.0.0.1:9433/appcast.xml
+SPARKLE_PUBLIC_KEY = WDzm5GHnc6c8kjeJEgX5GuGiPpW6Lc/ovGjLnrrZvPY=
+APPLICATION_SUPPORT_FOLDER = dev.com.intii.CopilotForXcode
EXTENSION_BUNDLE_NAME = Copilot Dev
+EXTENSION_BUNDLE_DISPLAY_NAME = Copilot Dev
+EXTENSION_SERVICE_NAME = CopilotForXcodeExtensionService
+
+// see also target Configs
diff --git a/Config.xcconfig b/Config.xcconfig
index 41aa1c72..81d6e2ba 100644
--- a/Config.xcconfig
+++ b/Config.xcconfig
@@ -1,4 +1,13 @@
#include "Version.xcconfig"
+SLASH = /
+HOST_APP_NAME = Copilot for Xcode
BUNDLE_IDENTIFIER_BASE = com.intii.CopilotForXcode
+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
+EXTENSION_BUNDLE_DISPLAY_NAME = Copilot
+EXTENSION_SERVICE_NAME = CopilotForXcodeExtensionService
+
+// see also target Configs
diff --git a/Copilot for Xcode.xcodeproj/project.pbxproj b/Copilot for Xcode.xcodeproj/project.pbxproj
index b4e3a3ad..056e5761 100644
--- a/Copilot for Xcode.xcodeproj/project.pbxproj
+++ b/Copilot for Xcode.xcodeproj/project.pbxproj
@@ -10,42 +10,51 @@
C8009BFF2941C551007AA7E8 /* ToggleRealtimeSuggestionsCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8009BFE2941C551007AA7E8 /* ToggleRealtimeSuggestionsCommand.swift */; };
C8009C032941C576007AA7E8 /* RealtimeSuggestionCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8009C022941C576007AA7E8 /* RealtimeSuggestionCommand.swift */; };
C800DBB1294C624D00B04CAC /* PrefetchSuggestionsCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C800DBB0294C624D00B04CAC /* PrefetchSuggestionsCommand.swift */; };
- C81291AE2994F8A000196E12 /* copilot in Copy CopilotLSP */ = {isa = PBXBuildFile; fileRef = C832A47B2940C71D000989F2 /* copilot */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
+ C80FFB962A95F58200704A25 /* AcceptPromptToCodeCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C80FFB952A95F58200704A25 /* AcceptPromptToCodeCommand.swift */; };
C81291D72994FE6900196E12 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = C81291D52994FE6900196E12 /* Main.storyboard */; };
C814588F2939EFDC00135263 /* Cocoa.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C814588E2939EFDC00135263 /* Cocoa.framework */; };
C81458942939EFDC00135263 /* SourceEditorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C81458932939EFDC00135263 /* SourceEditorExtension.swift */; };
C81458962939EFDC00135263 /* GetSuggestionsCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C81458952939EFDC00135263 /* GetSuggestionsCommand.swift */; };
C814589B2939EFDC00135263 /* Copilot.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = C814588C2939EFDC00135263 /* Copilot.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
C8189B1A2938972F00C9DCDA /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8189B192938972F00C9DCDA /* App.swift */; };
- C8189B1C2938972F00C9DCDA /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8189B1B2938972F00C9DCDA /* ContentView.swift */; };
C8189B1E2938973000C9DCDA /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C8189B1D2938973000C9DCDA /* Assets.xcassets */; };
C8189B212938973000C9DCDA /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C8189B202938973000C9DCDA /* Preview Assets.xcassets */; };
C8216B73298036EC00AD38C7 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8216B72298036EC00AD38C7 /* main.swift */; };
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, ); }; };
- C83B2B7A293D9C8C00C5ACCD /* LaunchAgentManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C83B2B79293D9C8C00C5ACCD /* LaunchAgentManager.swift */; };
- C83B2B7C293D9FB400C5ACCD /* CopilotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C83B2B7B293D9FB400C5ACCD /* CopilotView.swift */; };
- C83B2B7E293DA0CA00C5ACCD /* AppInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C83B2B7D293DA0CA00C5ACCD /* AppInfoView.swift */; };
- C83B2B80293DA1B600C5ACCD /* InstructionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C83B2B7F293DA1B600C5ACCD /* InstructionView.swift */; };
- C83B2B82293DC38400C5ACCD /* LaunchAgentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C83B2B81293DC38400C5ACCD /* LaunchAgentView.swift */; };
- C841BB242994CAD400B0B336 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C841BB232994CAD400B0B336 /* SettingsView.swift */; };
+ 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 */; };
C861E6152994F6080056CB02 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C861E6142994F6080056CB02 /* Assets.xcassets */; };
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 */; };
C87B03A7293B261900C77EAE /* RejectSuggestionCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C87B03A6293B261900C77EAE /* RejectSuggestionCommand.swift */; };
C87B03A9293B262600C77EAE /* NextSuggestionCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C87B03A8293B262600C77EAE /* NextSuggestionCommand.swift */; };
C87B03AB293B262E00C77EAE /* PreviousSuggestionCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C87B03AA293B262E00C77EAE /* PreviousSuggestionCommand.swift */; };
C87B03AC293B2CF300C77EAE /* XcodeKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C81458902939EFDC00135263 /* XcodeKit.framework */; };
C87B03AD293B2CF300C77EAE /* XcodeKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = C81458902939EFDC00135263 /* XcodeKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
- C87F3E60293DC600008523E8 /* Section.swift in Sources */ = {isa = PBXBuildFile; fileRef = C87F3E5F293DC600008523E8 /* Section.swift */; };
- C87F3E62293DD004008523E8 /* Styles.swift in Sources */ = {isa = PBXBuildFile; fileRef = C87F3E61293DD004008523E8 /* Styles.swift */; };
- C882175A294187E100A22FD3 /* Client in Frameworks */ = {isa = PBXBuildFile; productRef = C8821759294187E100A22FD3 /* Client */; };
C882175C294187EF00A22FD3 /* Client in Frameworks */ = {isa = PBXBuildFile; productRef = C882175B294187EF00A22FD3 /* Client */; };
- C8E93DB429950C8A00E6D43D /* CopilotForXcodeExtensionService.app in Embed XPCService */ = {isa = PBXBuildFile; fileRef = C861E60E2994F6070056CB02 /* CopilotForXcodeExtensionService.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
+ 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 /* 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 */
@@ -70,20 +79,16 @@
remoteGlobalIDString = C8216B6F298036EC00AD38C7;
remoteInfo = Helper;
};
+ C8738B8C2BE540F900609E7F /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = C8189B0E2938972F00C9DCDA /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = C8738B622BE4D4B900609E7F;
+ remoteInfo = CommunicationBridge;
+ };
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
- C81291AD2994F88D00196E12 /* Copy CopilotLSP */ = {
- isa = PBXCopyFilesBuildPhase;
- buildActionMask = 2147483647;
- dstPath = "";
- dstSubfolderSpec = 7;
- files = (
- C81291AE2994F8A000196E12 /* copilot in Copy CopilotLSP */,
- );
- name = "Copy CopilotLSP";
- runOnlyForDeploymentPostprocessing = 0;
- };
C814589F2939EFDC00135263 /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
@@ -104,26 +109,37 @@
);
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 = (
- C8E93DB429950C8A00E6D43D /* CopilotForXcodeExtensionService.app in Embed XPCService */,
+ C8738B8B2BE540DD00609E7F /* CommunicationBridge in Embed XPCService */,
C8216B802980378300AD38C7 /* Helper in Embed XPCService */,
+ C8C8B60929AFA35F00034BEE /* CopilotForXcodeExtensionService.app in Embed XPCService */,
);
name = "Embed XPCService";
runOnlyForDeploymentPostprocessing = 0;
};
- C861E6062994F50D0056CB02 /* Embed Old XPCService Target */ = {
+ C8738B612BE4D4B900609E7F /* CopyFiles */ = {
isa = PBXCopyFilesBuildPhase;
- buildActionMask = 2147483647;
+ buildActionMask = 12;
dstPath = "";
- dstSubfolderSpec = 6;
+ dstSubfolderSpec = 16;
files = (
);
- name = "Embed Old XPCService Target";
runOnlyForDeploymentPostprocessing = 0;
};
C87B03AE293B2CF300C77EAE /* Embed Frameworks */ = {
@@ -137,12 +153,34 @@
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
+ C8C8B60829AFA32800034BEE /* Embed Service */ = {
+ isa = PBXCopyFilesBuildPhase;
+ buildActionMask = 2147483647;
+ dstPath = "$(CONTENTS_FOLDER_PATH)/XPCServices";
+ dstSubfolderSpec = 16;
+ files = (
+ );
+ name = "Embed Service";
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ C8F1032A2A7A38D200D28F4F /* Copy Launch Agent */ = {
+ isa = PBXCopyFilesBuildPhase;
+ buildActionMask = 12;
+ dstPath = Contents/Library/LaunchAgents;
+ dstSubfolderSpec = 1;
+ files = (
+ C8738B8A2BE540D000609E7F /* bridgeLaunchAgent.plist in Copy Launch Agent */,
+ );
+ name = "Copy Launch Agent";
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
C8009BFE2941C551007AA7E8 /* ToggleRealtimeSuggestionsCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleRealtimeSuggestionsCommand.swift; sourceTree = ""; };
C8009C022941C576007AA7E8 /* RealtimeSuggestionCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RealtimeSuggestionCommand.swift; sourceTree = ""; };
C800DBB0294C624D00B04CAC /* PrefetchSuggestionsCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrefetchSuggestionsCommand.swift; sourceTree = ""; };
+ C80FFB952A95F58200704A25 /* AcceptPromptToCodeCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AcceptPromptToCodeCommand.swift; sourceTree = ""; };
C81291D52994FE6900196E12 /* Main.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = Main.storyboard; sourceTree = ""; };
C81291D92994FE7900196E12 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; };
C814588C2939EFDC00135263 /* Copilot.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = Copilot.appex; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -156,37 +194,52 @@
C81458AE293A009800135263 /* Config.debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config.debug.xcconfig; sourceTree = ""; };
C8189B162938972F00C9DCDA /* Copilot for Xcode Dev.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Copilot for Xcode Dev.app"; sourceTree = BUILT_PRODUCTS_DIR; };
C8189B192938972F00C9DCDA /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = ""; };
- C8189B1B2938972F00C9DCDA /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; };
C8189B1D2938973000C9DCDA /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
C8189B202938973000C9DCDA /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; };
C8189B222938973000C9DCDA /* Copilot_for_Xcode.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Copilot_for_Xcode.entitlements; sourceTree = ""; };
C8189B282938979000C9DCDA /* Core */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Core; sourceTree = ""; };
+ C81D181E2A1B509B006C1B70 /* Tool */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Tool; sourceTree = ""; };
C81E867D296FE4420026E908 /* Version.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Version.xcconfig; sourceTree = ""; };
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 = ""; };
- C832A47B2940C71D000989F2 /* copilot */ = {isa = PBXFileReference; lastKnownFileType = folder; name = copilot; path = copilot.vim/copilot; sourceTree = ""; };
- C83B2B79293D9C8C00C5ACCD /* LaunchAgentManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchAgentManager.swift; sourceTree = ""; };
- C83B2B7B293D9FB400C5ACCD /* CopilotView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopilotView.swift; sourceTree = ""; };
- C83B2B7D293DA0CA00C5ACCD /* AppInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppInfoView.swift; sourceTree = ""; };
- C83B2B7F293DA1B600C5ACCD /* InstructionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstructionView.swift; sourceTree = ""; };
- C83B2B81293DC38400C5ACCD /* LaunchAgentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchAgentView.swift; sourceTree = ""; };
- C841BB232994CAD400B0B336 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; };
+ C828B27D2B1F241500E7612A /* ExtensionPoint.appextensionpoint */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = ExtensionPoint.appextensionpoint; sourceTree = ""; };
+ C82E38492A1F025F00D4EADF /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE; sourceTree = ""; };
+ C84FD9D72CC671C600BE5093 /* ChatPlugins */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = ChatPlugins; sourceTree = ""; };
C8520300293C4D9000460097 /* Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Helpers.swift; sourceTree = ""; };
C8520308293D805800460097 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; };
+ C861A6A229E5503F005C41A3 /* PromptToCodeCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptToCodeCommand.swift; sourceTree = ""; };
C861E60E2994F6070056CB02 /* CopilotForXcodeExtensionService.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CopilotForXcodeExtensionService.app; sourceTree = BUILT_PRODUCTS_DIR; };
C861E6102994F6070056CB02 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
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; };
C87B03A4293B261200C77EAE /* AcceptSuggestionCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AcceptSuggestionCommand.swift; sourceTree = ""; };
C87B03A6293B261900C77EAE /* RejectSuggestionCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RejectSuggestionCommand.swift; sourceTree = ""; };
C87B03A8293B262600C77EAE /* NextSuggestionCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NextSuggestionCommand.swift; sourceTree = ""; };
C87B03AA293B262E00C77EAE /* PreviousSuggestionCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviousSuggestionCommand.swift; sourceTree = ""; };
- C87F3E5F293DC600008523E8 /* Section.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Section.swift; sourceTree = ""; };
- C87F3E61293DD004008523E8 /* Styles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Styles.swift; sourceTree = ""; };
C887BC832965D96000931567 /* DEVELOPMENT.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = DEVELOPMENT.md; sourceTree = ""; };
+ C89E75C22A46FB32000DD64F /* AppDelegate+Menu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+Menu.swift"; sourceTree = ""; };
+ C8BE64922EB9B42E00EDB2D7 /* OverlayWindow */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = OverlayWindow; sourceTree = ""; };
+ C8CD828229B88006008D044D /* TestPlan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = TestPlan.xctestplan; sourceTree = ""; };
+ C8DCEFFF29CE11D500FDDDD7 /* OpenChat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenChat.swift; sourceTree = ""; };
+ C8DD9CB02BC673F80036641C /* CloseIdleTabsCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseIdleTabsCommand.swift; sourceTree = ""; };
+ C8F103292A7A365000D28F4F /* launchAgent.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = launchAgent.plist; sourceTree = ""; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -204,7 +257,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
- C882175A294187E100A22FD3 /* Client in Frameworks */,
+ C86612F82A06AF74009197D9 /* HostApp in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -224,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 */
@@ -240,15 +309,21 @@
isa = PBXGroup;
children = (
C81458932939EFDC00135263 /* SourceEditorExtension.swift */,
+ C8758E7129F04CF100D29C1C /* SeparatorCommand.swift */,
C8520300293C4D9000460097 /* Helpers.swift */,
C81458952939EFDC00135263 /* GetSuggestionsCommand.swift */,
C87B03A4293B261200C77EAE /* AcceptSuggestionCommand.swift */,
+ C80FFB952A95F58200704A25 /* AcceptPromptToCodeCommand.swift */,
C87B03A6293B261900C77EAE /* RejectSuggestionCommand.swift */,
C87B03A8293B262600C77EAE /* NextSuggestionCommand.swift */,
C87B03AA293B262E00C77EAE /* PreviousSuggestionCommand.swift */,
C8009BFE2941C551007AA7E8 /* ToggleRealtimeSuggestionsCommand.swift */,
C8009C022941C576007AA7E8 /* RealtimeSuggestionCommand.swift */,
C800DBB0294C624D00B04CAC /* PrefetchSuggestionsCommand.swift */,
+ C8758E6F29F04BFF00D29C1C /* CustomCommand.swift */,
+ C8DCEFFF29CE11D500FDDDD7 /* OpenChat.swift */,
+ C8DD9CB02BC673F80036641C /* CloseIdleTabsCommand.swift */,
+ C861A6A229E5503F005C41A3 /* PromptToCodeCommand.swift */,
C81458972939EFDC00135263 /* Info.plist */,
C81458982939EFDC00135263 /* EditorExtension.entitlements */,
);
@@ -258,17 +333,26 @@
C8189B0D2938972F00C9DCDA = {
isa = PBXGroup;
children = (
- C832A47B2940C71D000989F2 /* copilot */,
- C8520308293D805800460097 /* README.md */,
C887BC832965D96000931567 /* DEVELOPMENT.md */,
+ C8520308293D805800460097 /* README.md */,
+ C82E38492A1F025F00D4EADF /* LICENSE */,
+ 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 */,
);
@@ -281,6 +365,8 @@
C814588C2939EFDC00135263 /* Copilot.appex */,
C8216B70298036EC00AD38C7 /* Helper */,
C861E60E2994F6070056CB02 /* CopilotForXcodeExtensionService.app */,
+ C8738B632BE4D4B900609E7F /* CommunicationBridge */,
+ C8738B782BE5363800609E7F /* SandboxedClientTester.app */,
);
name = Products;
sourceTree = "";
@@ -289,16 +375,7 @@
isa = PBXGroup;
children = (
C87B03A3293B24AB00C77EAE /* Copilot-for-Xcode-Info.plist */,
- C83B2B79293D9C8C00C5ACCD /* LaunchAgentManager.swift */,
- C87F3E5F293DC600008523E8 /* Section.swift */,
C8189B192938972F00C9DCDA /* App.swift */,
- C8189B1B2938972F00C9DCDA /* ContentView.swift */,
- C83B2B81293DC38400C5ACCD /* LaunchAgentView.swift */,
- C83B2B7B293D9FB400C5ACCD /* CopilotView.swift */,
- C83B2B7D293DA0CA00C5ACCD /* AppInfoView.swift */,
- C83B2B7F293DA1B600C5ACCD /* InstructionView.swift */,
- C841BB232994CAD400B0B336 /* SettingsView.swift */,
- C87F3E61293DD004008523E8 /* Styles.swift */,
C8189B1D2938973000C9DCDA /* Assets.xcassets */,
C8189B222938973000C9DCDA /* Copilot_for_Xcode.entitlements */,
C8189B1F2938973000C9DCDA /* Preview Content */,
@@ -329,6 +406,8 @@
C81291D92994FE7900196E12 /* Info.plist */,
C861E61F2994F6390056CB02 /* ServiceDelegate.swift */,
C861E6102994F6070056CB02 /* AppDelegate.swift */,
+ C89E75C22A46FB32000DD64F /* AppDelegate+Menu.swift */,
+ C8738B702BE4F8B700609E7F /* XPCController.swift */,
C81291D52994FE6900196E12 /* Main.storyboard */,
C861E6142994F6080056CB02 /* Assets.xcassets */,
C861E6192994F6080056CB02 /* ExtensionService.entitlements */,
@@ -336,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 */
@@ -369,18 +478,20 @@
C8189B142938972F00C9DCDA /* Resources */,
C814589F2939EFDC00135263 /* Embed Foundation Extensions */,
C8520306293CF0EF00460097 /* Embed XPCService */,
- C861E6062994F50D0056CB02 /* Embed Old XPCService Target */,
+ C8C8B60829AFA32800034BEE /* Embed Service */,
+ C8F1032A2A7A38D200D28F4F /* Copy Launch Agent */,
);
buildRules = (
);
dependencies = (
+ C8738B8D2BE540F900609E7F /* PBXTargetDependency */,
C81291B02994F92700196E12 /* PBXTargetDependency */,
C8216B7F2980377E00AD38C7 /* PBXTargetDependency */,
C814589A2939EFDC00135263 /* PBXTargetDependency */,
);
name = "Copilot for Xcode";
packageProductDependencies = (
- C8821759294187E100A22FD3 /* Client */,
+ C86612F72A06AF74009197D9 /* HostApp */,
);
productName = "Copilot for Xcode";
productReference = C8189B162938972F00C9DCDA /* Copilot for Xcode Dev.app */;
@@ -413,7 +524,7 @@
C861E60A2994F6070056CB02 /* Sources */,
C861E60B2994F6070056CB02 /* Frameworks */,
C861E60C2994F6070056CB02 /* Resources */,
- C81291AD2994F88D00196E12 /* Copy CopilotLSP */,
+ C828B27E2B1F7B3C00E7612A /* Copy Extension Point */,
);
buildRules = (
);
@@ -427,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 */
@@ -434,7 +585,7 @@
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
- LastSwiftUpdateCheck = 1420;
+ LastSwiftUpdateCheck = 1520;
LastUpgradeCheck = 1410;
TargetAttributes = {
C814588B2939EFDC00135263 = {
@@ -449,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" */;
@@ -462,6 +619,7 @@
mainGroup = C8189B0D2938972F00C9DCDA;
packageReferences = (
C8216B792980373800AD38C7 /* XCRemoteSwiftPackageReference "swift-argument-parser" */,
+ C80C91742A588DD800B5EADA /* XCRemoteSwiftPackageReference "usearch" */,
);
productRefGroup = C8189B172938972F00C9DCDA /* Products */;
projectDirPath = "";
@@ -471,6 +629,8 @@
C814588B2939EFDC00135263 /* EditorExtension */,
C8216B6F298036EC00AD38C7 /* Helper */,
C861E60D2994F6070056CB02 /* ExtensionService */,
+ C8738B622BE4D4B900609E7F /* CommunicationBridge */,
+ C8738B772BE5363800609E7F /* SandboxedClientTester */,
);
};
/* End PBXProject section */
@@ -501,6 +661,15 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ C8738B762BE5363800609E7F /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ C8738B822BE5363900609E7F /* Preview Assets.xcassets in Resources */,
+ C8738B7F2BE5363900609E7F /* Assets.xcassets in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -508,9 +677,15 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
+ 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 */,
C8520301293C4D9000460097 /* Helpers.swift in Sources */,
C8009BFF2941C551007AA7E8 /* ToggleRealtimeSuggestionsCommand.swift in Sources */,
+ C80FFB962A95F58200704A25 /* AcceptPromptToCodeCommand.swift in Sources */,
C87B03A5293B261200C77EAE /* AcceptSuggestionCommand.swift in Sources */,
C87B03A9293B262600C77EAE /* NextSuggestionCommand.swift in Sources */,
C87B03AB293B262E00C77EAE /* PreviousSuggestionCommand.swift in Sources */,
@@ -525,15 +700,6 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
- C8189B1C2938972F00C9DCDA /* ContentView.swift in Sources */,
- C87F3E60293DC600008523E8 /* Section.swift in Sources */,
- C87F3E62293DD004008523E8 /* Styles.swift in Sources */,
- C83B2B82293DC38400C5ACCD /* LaunchAgentView.swift in Sources */,
- C83B2B7E293DA0CA00C5ACCD /* AppInfoView.swift in Sources */,
- C841BB242994CAD400B0B336 /* SettingsView.swift in Sources */,
- C83B2B80293DA1B600C5ACCD /* InstructionView.swift in Sources */,
- C83B2B7C293D9FB400C5ACCD /* CopilotView.swift in Sources */,
- C83B2B7A293D9C8C00C5ACCD /* LaunchAgentManager.swift in Sources */,
C8189B1A2938972F00C9DCDA /* App.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -551,11 +717,31 @@
isa = PBXSourcesBuildPhase;
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 */
@@ -574,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 */
@@ -584,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;
@@ -611,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;
@@ -665,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;
@@ -726,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;
@@ -756,23 +953,25 @@
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;
- PRODUCT_NAME = "Copilot for Xcode Dev";
+ PRODUCT_NAME = "$(HOST_APP_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
};
@@ -788,22 +987,24 @@
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 = "Copilot for Xcode";
+ PRODUCT_NAME = "$(HOST_APP_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
};
@@ -813,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;
@@ -826,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;
@@ -844,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;
@@ -857,10 +1061,10 @@
"$(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 = "CopilotForXcode$(TARGET_NAME)";
+ PRODUCT_NAME = "$(EXTENSION_SERVICE_NAME)";
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
@@ -876,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;
@@ -889,11 +1094,115 @@
"$(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 = "CopilotForXcode$(TARGET_NAME)";
+ PRODUCT_NAME = "$(EXTENSION_SERVICE_NAME)";
+ SKIP_INSTALL = YES;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_VERSION = 5.0;
+ };
+ 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;
};
@@ -947,9 +1256,35 @@
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 */
+ C80C91742A588DD800B5EADA /* XCRemoteSwiftPackageReference "usearch" */ = {
+ isa = XCRemoteSwiftPackageReference;
+ repositoryURL = "https://github.com/unum-cloud/usearch";
+ requirement = {
+ kind = upToNextMajorVersion;
+ minimumVersion = 0.19.1;
+ };
+ };
C8216B792980373800AD38C7 /* XCRemoteSwiftPackageReference "swift-argument-parser" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/apple/swift-argument-parser.git";
@@ -970,7 +1305,15 @@
isa = XCSwiftPackageProductDependency;
productName = Service;
};
- C8821759294187E100A22FD3 /* Client */ = {
+ C86612F72A06AF74009197D9 /* HostApp */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = HostApp;
+ };
+ C8738B6E2BE4F7A600609E7F /* XPCShared */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = XPCShared;
+ };
+ C8738B872BE5365000609E7F /* Client */ = {
isa = XCSwiftPackageProductDependency;
productName = Client;
};
diff --git a/Copilot for Xcode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Copilot for Xcode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
index 67e4ef2a..6cfaff01 100644
--- a/Copilot for Xcode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
+++ b/Copilot for Xcode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -1,5 +1,14 @@
{
"pins" : [
+ {
+ "identity" : "combine-schedulers",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/combine-schedulers",
+ "state" : {
+ "revision" : "0625932976b3ae23949f6b816d13bd97f3b40b7c",
+ "version" : "0.10.0"
+ }
+ },
{
"identity" : "fseventswrapper",
"kind" : "remoteSourceControl",
@@ -18,6 +27,15 @@
"version" : "1.0.5"
}
},
+ {
+ "identity" : "highlightr",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/raspu/Highlightr",
+ "state" : {
+ "revision" : "93199b9e434f04bda956a613af8f571933f9f037",
+ "version" : "2.1.2"
+ }
+ },
{
"identity" : "jsonrpc",
"kind" : "remoteSourceControl",
@@ -27,6 +45,15 @@
"version" : "0.6.0"
}
},
+ {
+ "identity" : "keychainaccess",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/kishikawakatsumi/KeychainAccess",
+ "state" : {
+ "revision" : "84e546727d66f1adc5439debad16270d0fdd04e7",
+ "version" : "4.2.2"
+ }
+ },
{
"identity" : "languageclient",
"kind" : "remoteSourceControl",
@@ -63,13 +90,166 @@
"version" : "0.3.1"
}
},
+ {
+ "identity" : "sparkle",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/sparkle-project/Sparkle",
+ "state" : {
+ "revision" : "631846cc829f0f0cae327df9bafe5a32b7ddadce",
+ "version" : "2.4.0"
+ }
+ },
+ {
+ "identity" : "splash",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/JohnSundell/Splash",
+ "state" : {
+ "branch" : "master",
+ "revision" : "2e3f17c2d09689c8bf175c4a84ff7f2ad3353301"
+ }
+ },
{
"identity" : "swift-argument-parser",
"kind" : "remoteSourceControl",
- "location" : "https://github.com/apple/swift-argument-parser",
+ "location" : "https://github.com/apple/swift-argument-parser.git",
+ "state" : {
+ "revision" : "fee6933f37fde9a5e12a1e4aeaa93fe60116ff2a",
+ "version" : "1.2.2"
+ }
+ },
+ {
+ "identity" : "swift-async-algorithms",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-async-algorithms",
+ "state" : {
+ "revision" : "9cfed92b026c524674ed869a4ff2dcfdeedf8a2a",
+ "version" : "0.1.0"
+ }
+ },
+ {
+ "identity" : "swift-case-paths",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-case-paths",
+ "state" : {
+ "revision" : "fc45e7b2cfece9dd80b5a45e6469ffe67fe67984",
+ "version" : "0.14.1"
+ }
+ },
+ {
+ "identity" : "swift-clocks",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-clocks",
+ "state" : {
+ "revision" : "f9acfa1a45f4483fe0f2c434a74e6f68f865d12d",
+ "version" : "0.3.0"
+ }
+ },
+ {
+ "identity" : "swift-collections",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-collections.git",
+ "state" : {
+ "revision" : "937e904258d22af6e447a0b72c0bc67583ef64a2",
+ "version" : "1.0.4"
+ }
+ },
+ {
+ "identity" : "swift-composable-architecture",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-composable-architecture",
+ "state" : {
+ "revision" : "89f80fe2400d21a853abc9556a060a2fa50eb2cb",
+ "version" : "0.55.0"
+ }
+ },
+ {
+ "identity" : "swift-custom-dump",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-custom-dump",
+ "state" : {
+ "revision" : "3a35f7892e7cf6ba28a78cd46a703c0be4e0c6dc",
+ "version" : "0.11.0"
+ }
+ },
+ {
+ "identity" : "swift-dependencies",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-dependencies",
+ "state" : {
+ "revision" : "de1a984a71e51f6e488e98ce3652035563eb8acb",
+ "version" : "0.5.1"
+ }
+ },
+ {
+ "identity" : "swift-identified-collections",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-identified-collections",
+ "state" : {
+ "revision" : "d01446a78fb768adc9a78cbb6df07767c8ccfc29",
+ "version" : "0.8.0"
+ }
+ },
+ {
+ "identity" : "swift-markdown-ui",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/gonzalezreal/swift-markdown-ui",
+ "state" : {
+ "revision" : "12b351a75201a8124c2f2e1f9fc6ef5cd812c0b9",
+ "version" : "2.1.0"
+ }
+ },
+ {
+ "identity" : "swift-parsing",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-parsing",
+ "state" : {
+ "revision" : "27c941bbd22a4bbc53005a15a0440443fd892f70",
+ "version" : "0.12.1"
+ }
+ },
+ {
+ "identity" : "swiftsoup",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/scinfu/SwiftSoup.git",
+ "state" : {
+ "revision" : "0e96a20ffd37a515c5c963952d4335c89bed50a6",
+ "version" : "2.6.0"
+ }
+ },
+ {
+ "identity" : "swiftui-navigation",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swiftui-navigation",
+ "state" : {
+ "revision" : "2aa885e719087ee19df251c08a5980ad3e787f12",
+ "version" : "0.8.0"
+ }
+ },
+ {
+ "identity" : "tiktoken",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/intitni/Tiktoken",
+ "state" : {
+ "branch" : "main",
+ "revision" : "6a2ac324c6daec167ca95268d5a487e6de6a1cea"
+ }
+ },
+ {
+ "identity" : "usearch",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/unum-cloud/usearch",
+ "state" : {
+ "revision" : "f2ab884d50902c3ad63f07a3a20bc34008a17449",
+ "version" : "0.19.3"
+ }
+ },
+ {
+ "identity" : "xctest-dynamic-overlay",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay",
"state" : {
- "revision" : "4ad606ba5d7673ea60679a61ff867cc1ff8c8e86",
- "version" : "1.2.1"
+ "revision" : "4af50b38daf0037cfbab15514a241224c3f62f98",
+ "version" : "0.8.5"
}
}
],
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 bf8f3de0..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,7 +1,7 @@
+ LastUpgradeVersion = "1520"
+ version = "1.7">
@@ -27,8 +27,12 @@
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/SandboxedClientTester.xcscheme b/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/SandboxedClientTester.xcscheme
new file mode 100644
index 00000000..41fadd0b
--- /dev/null
+++ b/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/SandboxedClientTester.xcscheme
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Copilot for Xcode.xcworkspace/contents.xcworkspacedata b/Copilot for Xcode.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 00000000..77c34844
--- /dev/null
+++ b/Copilot for Xcode.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
diff --git a/Copilot for Xcode.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Copilot for Xcode.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
new file mode 100644
index 00000000..18d98100
--- /dev/null
+++ b/Copilot for Xcode.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ IDEDidComputeMac32BitWarning
+
+
+
diff --git a/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved
new file mode 100644
index 00000000..87fd4d4e
--- /dev/null
+++ b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -0,0 +1,446 @@
+{
+ "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",
+ "location" : "https://github.com/GottaGetSwifty/CodableWrappers",
+ "state" : {
+ "revision" : "4eb46a4c656333e8514db8aad204445741de7d40",
+ "version" : "2.0.7"
+ }
+ },
+ {
+ "identity" : "combine-schedulers",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/combine-schedulers",
+ "state" : {
+ "revision" : "5928286acce13def418ec36d05a001a9641086f2",
+ "version" : "1.0.3"
+ }
+ },
+ {
+ "identity" : "copilotforxcodekit",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/intitni/CopilotForXcodeKit",
+ "state" : {
+ "branch" : "feature/custom-chat-tab",
+ "revision" : "63915ee1f8aba5375bc0f0166c8645fe81fe5b88"
+ }
+ },
+ {
+ "identity" : "fseventswrapper",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/Frizlab/FSEventsWrapper",
+ "state" : {
+ "revision" : "e0c59a2ce2775e5f6642da6d19207445f10112d0",
+ "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",
+ "location" : "https://github.com/Bouke/Glob",
+ "state" : {
+ "revision" : "deda6e163d2ff2a8d7e138e2c3326dbd71157faf",
+ "version" : "1.0.5"
+ }
+ },
+ {
+ "identity" : "highlightr",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/intitni/Highlightr",
+ "state" : {
+ "branch" : "master",
+ "revision" : "81d8c8b3733939bf5d9e52cd6318f944cc033bd2"
+ }
+ },
+ {
+ "identity" : "indexstore-db",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/indexstore-db.git",
+ "state" : {
+ "branch" : "release/6.1",
+ "revision" : "54212fce1aecb199070808bdb265e7f17e396015"
+ }
+ },
+ {
+ "identity" : "jsonrpc",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/ChimeHQ/JSONRPC",
+ "state" : {
+ "revision" : "5da978702aece6ba5c7879b0d253c180d61e4ef3",
+ "version" : "0.6.0"
+ }
+ },
+ {
+ "identity" : "keyboardshortcuts",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/intitni/KeyboardShortcuts",
+ "state" : {
+ "branch" : "main",
+ "revision" : "65fb410b0c6d3ed96623b460bab31ffce5f48b4d"
+ }
+ },
+ {
+ "identity" : "languageclient",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/ChimeHQ/LanguageClient",
+ "state" : {
+ "revision" : "f0198ee0a102d266078f7d9c28f086f2989f988a",
+ "version" : "0.3.1"
+ }
+ },
+ {
+ "identity" : "languageserverprotocol",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/ChimeHQ/LanguageServerProtocol",
+ "state" : {
+ "revision" : "6e97f943dc024307c5524a80bd33cdbd1cc621de",
+ "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",
+ "location" : "https://github.com/ChimeHQ/OperationPlus",
+ "state" : {
+ "revision" : "1340f95dce3e93d742497d88db18f8676f4badf4",
+ "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",
+ "location" : "https://github.com/ChimeHQ/ProcessEnv",
+ "state" : {
+ "revision" : "29487b6581bb785c372c611c943541ef4309d051",
+ "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" : "0ca3004e98712ea2b39dd881d28448630cce1c99",
+ "version" : "2.7.0"
+ }
+ },
+ {
+ "identity" : "spectre",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/kylef/Spectre.git",
+ "state" : {
+ "revision" : "26cc5e9ae0947092c7139ef7ba612e34646086c7",
+ "version" : "0.10.1"
+ }
+ },
+ {
+ "identity" : "swift-argument-parser",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-argument-parser.git",
+ "state" : {
+ "revision" : "fee6933f37fde9a5e12a1e4aeaa93fe60116ff2a",
+ "version" : "1.2.2"
+ }
+ },
+ {
+ "identity" : "swift-async-algorithms",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-async-algorithms",
+ "state" : {
+ "revision" : "042e1c4d9d19748c9c228f8d4ebc97bb1e339b0b",
+ "version" : "1.0.4"
+ }
+ },
+ {
+ "identity" : "swift-case-paths",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-case-paths",
+ "state" : {
+ "revision" : "41b89b8b68d8c56c622dbb7132258f1a3e638b25",
+ "version" : "1.7.0"
+ }
+ },
+ {
+ "identity" : "swift-clocks",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-clocks",
+ "state" : {
+ "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"
+ }
+ },
+ {
+ "identity" : "swift-collections",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-collections",
+ "state" : {
+ "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7",
+ "version" : "1.1.4"
+ }
+ },
+ {
+ "identity" : "swift-composable-architecture",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-composable-architecture",
+ "state" : {
+ "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"
+ }
+ },
+ {
+ "identity" : "swift-custom-dump",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-custom-dump",
+ "state" : {
+ "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1",
+ "version" : "1.3.3"
+ }
+ },
+ {
+ "identity" : "swift-dependencies",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-dependencies",
+ "state" : {
+ "revision" : "4c90d6b2b9bf0911af87b103bb40f41771891596",
+ "version" : "1.9.2"
+ }
+ },
+ {
+ "identity" : "swift-identified-collections",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-identified-collections",
+ "state" : {
+ "revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597",
+ "version" : "1.1.1"
+ }
+ },
+ {
+ "identity" : "swift-markdown-ui",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/gonzalezreal/swift-markdown-ui",
+ "state" : {
+ "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"
+ }
+ },
+ {
+ "identity" : "swift-parsing",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-parsing",
+ "state" : {
+ "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"
+ }
+ },
+ {
+ "identity" : "swift-syntax",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-syntax.git",
+ "state" : {
+ "revision" : "0687f71944021d616d34d922343dcef086855920",
+ "version" : "600.0.1"
+ }
+ },
+ {
+ "identity" : "swiftsoup",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/scinfu/SwiftSoup.git",
+ "state" : {
+ "revision" : "8b6cf29eead8841a1fa7822481cb3af4ddaadba6",
+ "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/intitni/SwiftTreeSitter.git",
+ "state" : {
+ "branch" : "main",
+ "revision" : "fd499bfafcccfae12a1a579dc922d8418025a35d"
+ }
+ },
+ {
+ "identity" : "swiftui-introspect",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/siteline/swiftui-introspect",
+ "state" : {
+ "revision" : "807f73ce09a9b9723f12385e592b4e0aaebd3336",
+ "version" : "1.3.0"
+ }
+ },
+ {
+ "identity" : "swxmlhash",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/drmohundro/SWXMLHash.git",
+ "state" : {
+ "revision" : "a853604c9e9a83ad9954c7e3d2a565273982471f",
+ "version" : "7.0.2"
+ }
+ },
+ {
+ "identity" : "tiktoken",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/intitni/Tiktoken",
+ "state" : {
+ "branch" : "main",
+ "revision" : "6a2ac324c6daec167ca95268d5a487e6de6a1cea"
+ }
+ },
+ {
+ "identity" : "tree-sitter-objc",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/lukepistrol/tree-sitter-objc",
+ "state" : {
+ "branch" : "feature/spm",
+ "revision" : "1b54ef0b5efddddf393b45e173788499cc572048"
+ }
+ },
+ {
+ "identity" : "usearch",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/unum-cloud/usearch",
+ "state" : {
+ "revision" : "f2ab884d50902c3ad63f07a3a20bc34008a17449",
+ "version" : "0.19.3"
+ }
+ },
+ {
+ "identity" : "xcodeproj",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/tuist/XcodeProj.git",
+ "state" : {
+ "revision" : "b1caa062d4aaab3e3d2bed5fe0ac5f8ce9bf84f4",
+ "version" : "8.27.7"
+ }
+ },
+ {
+ "identity" : "xctest-dynamic-overlay",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay",
+ "state" : {
+ "revision" : "39de59b2d47f7ef3ca88a039dff3084688fe27f4",
+ "version" : "1.5.2"
+ }
+ },
+ {
+ "identity" : "yams",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/jpsim/Yams.git",
+ "state" : {
+ "revision" : "b4b8042411dc7bbb696300a34a4bf3ba1b7ad19b",
+ "version" : "5.3.1"
+ }
+ }
+ ],
+ "version" : 2
+}
diff --git a/Copilot for Xcode/App.swift b/Copilot for Xcode/App.swift
index 6ec279fe..99a0a044 100644
--- a/Copilot for Xcode/App.swift
+++ b/Copilot for Xcode/App.swift
@@ -1,15 +1,51 @@
+import Client
+import HostApp
+import LaunchAgentManager
import SwiftUI
+import UpdateChecker
+import XPCShared
+
+struct VisualEffect: NSViewRepresentable {
+ 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 {
WindowGroup {
- ContentView()
- .frame(minWidth: 500, minHeight: 700)
- .preferredColorScheme(.dark)
+ TabContainer()
+ .frame(minWidth: 800, minHeight: 600)
+ .background(VisualEffect().ignoresSafeArea())
+ .onAppear {
+ UserDefaults.setupDefaultSettings()
+ }
+ .environment(
+ \.updateChecker,
+ {
+ let checker = UpdateChecker(
+ hostBundle: Bundle.main,
+ shouldAutomaticallyCheckForUpdate: false
+ )
+ checker.updateCheckerDelegate = updateCheckerDelegate
+ return checker
+ }()
+ )
}
- .windowStyle(.hiddenTitleBar)
}
}
var isPreview: Bool { ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" }
+
diff --git a/Copilot for Xcode/AppInfoView.swift b/Copilot for Xcode/AppInfoView.swift
deleted file mode 100644
index ba40e377..00000000
--- a/Copilot for Xcode/AppInfoView.swift
+++ /dev/null
@@ -1,36 +0,0 @@
-import SwiftUI
-
-struct AppInfoView: View {
- @State var appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
-
- var body: some View {
- Section {
- VStack(alignment: .leading) {
- HStack(alignment: .top) {
- Text("Copilot For Xcode")
- .font(.title)
- Text(appVersion ?? "")
- .font(.footnote)
- .foregroundColor(.white.opacity(0.5))
-
- Spacer()
- }
-
- Link(destination: URL(string: "https://github.com/intitni/CopilotForXcode")!) {
- HStack(spacing: 2) {
- Image(systemName: "link")
- Text("GitHub")
- }
- }
- .focusable(false)
- }
- }
- }
-}
-
-struct AppInfoView_Preview: PreviewProvider {
- static var previews: some View {
- AppInfoView()
- .background(.black)
- }
-}
diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/Contents.json b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/Contents.json
index a03c1da5..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" : "app-icon-realistic-materials_2x 9.png",
+ "filename" : "app-icon@16w.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
- "filename" : "app-icon-realistic-materials_2x 8.png",
+ "filename" : "app-icon@32w.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
- "filename" : "app-icon-realistic-materials_2x 7.png",
+ "filename" : "app-icon@32w.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
- "filename" : "app-icon-realistic-materials_2x 6.png",
+ "filename" : "app-icon@64w.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
- "filename" : "app-icon-realistic-materials_2x 5.png",
+ "filename" : "app-icon@128w.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
- "filename" : "app-icon-realistic-materials_2x 4.png",
+ "filename" : "app-icon@256w.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
- "filename" : "app-icon-realistic-materials_2x 3.png",
+ "filename" : "app-icon@256w.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
- "filename" : "app-icon-realistic-materials_2x 2.png",
+ "filename" : "app-icon@512w.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
- "filename" : "app-icon-realistic-materials_2x 1.png",
+ "filename" : "app-icon@512w.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
- "filename" : "app-icon-realistic-materials_2x.png",
+ "filename" : "app-icon.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon-realistic-materials_2x 1.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon-realistic-materials_2x 1.png
deleted file mode 100644
index 267da1f8..00000000
Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon-realistic-materials_2x 1.png and /dev/null differ
diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon-realistic-materials_2x 2.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon-realistic-materials_2x 2.png
deleted file mode 100644
index 267da1f8..00000000
Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon-realistic-materials_2x 2.png and /dev/null differ
diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon-realistic-materials_2x 3.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon-realistic-materials_2x 3.png
deleted file mode 100644
index ff87360f..00000000
Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon-realistic-materials_2x 3.png and /dev/null differ
diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon-realistic-materials_2x 4.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon-realistic-materials_2x 4.png
deleted file mode 100644
index ff87360f..00000000
Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon-realistic-materials_2x 4.png and /dev/null differ
diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon-realistic-materials_2x 5.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon-realistic-materials_2x 5.png
deleted file mode 100644
index 48f29e28..00000000
Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon-realistic-materials_2x 5.png and /dev/null differ
diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon-realistic-materials_2x 6.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon-realistic-materials_2x 6.png
deleted file mode 100644
index 51fc4d9b..00000000
Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon-realistic-materials_2x 6.png and /dev/null differ
diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon-realistic-materials_2x 7.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon-realistic-materials_2x 7.png
deleted file mode 100644
index 9f23c1ae..00000000
Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon-realistic-materials_2x 7.png and /dev/null differ
diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon-realistic-materials_2x 8.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon-realistic-materials_2x 8.png
deleted file mode 100644
index 9f23c1ae..00000000
Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon-realistic-materials_2x 8.png and /dev/null differ
diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon-realistic-materials_2x 9.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon-realistic-materials_2x 9.png
deleted file mode 100644
index 3ccd8a06..00000000
Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon-realistic-materials_2x 9.png and /dev/null differ
diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon-realistic-materials_2x.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon-realistic-materials_2x.png
deleted file mode 100644
index 2d98e582..00000000
Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon-realistic-materials_2x.png and /dev/null differ
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/ContentView.swift b/Copilot for Xcode/ContentView.swift
deleted file mode 100644
index 5bc08356..00000000
--- a/Copilot for Xcode/ContentView.swift
+++ /dev/null
@@ -1,37 +0,0 @@
-import AppKit
-import CopilotModel
-import SwiftUI
-
-struct ContentView: View {
- @Environment(\.openURL) var openURL
- @AppStorage("username") var username: String = ""
- @State var copilotStatus: CopilotStatus?
- @State var message: String?
- @State var userCode: String?
-
- var body: some View {
- ScrollView {
- VStack(alignment: .leading, spacing: 8) {
- AppInfoView()
- LaunchAgentView()
- CopilotView()
- SettingsView()
- InstructionView()
- Spacer()
- }
- .padding(.all, 12)
- }
- .background(LinearGradient(
- colors: [Color("BackgroundColorTop"), Color("BackgroundColor")],
- startPoint: .topLeading,
- endPoint: .bottom
- ))
- }
-}
-
-struct ContentView_Previews: PreviewProvider {
- static var previews: some View {
- ContentView()
- .frame(height: 800)
- }
-}
diff --git a/Copilot for Xcode/CopilotView.swift b/Copilot for Xcode/CopilotView.swift
deleted file mode 100644
index a2ec6914..00000000
--- a/Copilot for Xcode/CopilotView.swift
+++ /dev/null
@@ -1,185 +0,0 @@
-import AppKit
-import Client
-import CopilotModel
-import SwiftUI
-
-struct CopilotView: View {
- @Environment(\.openURL) var openURL
- @AppStorage("username") var username: String = ""
- @State var copilotStatus: CopilotStatus?
- @State var message: String?
- @State var userCode: String?
- @State var version: String?
- @State var isRunningAction: Bool = false
- @State var isUserCodeCopiedAlertPresented = false
- @State var xpcServiceVersion: String?
- var shouldRestartXPCService: Bool {
- xpcServiceVersion != (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String)
- }
-
- var body: some View {
- Section {
- HStack {
- VStack(alignment: .leading, spacing: 8) {
- Text("Copilot")
- .font(.title)
- .padding(.bottom, 12)
- Text("XPCService Version: \(xpcServiceVersion ?? "Loading..")")
- Text("Copilot Version: \(version ?? "Loading..")")
- Text("Status: \(copilotStatus?.description ?? "Loading..")")
- HStack(alignment: .center) {
- Button("Refresh") { checkStatus() }
- if copilotStatus == .notSignedIn {
- Button("Sign In") { signIn() }
- .alert(isPresented: $isUserCodeCopiedAlertPresented) {
- Alert(
- title: Text(userCode ?? ""),
- message: Text(
- "The user code is pasted into your clipboard, please paste it in the opened website to login.\nAfter that, click \"Confirm Sign-in\" to finish."
- ),
- dismissButton: .default(Text("OK"))
- )
- }
- Button("Confirm Sign-in") { confirmSignIn() }
- }
- if copilotStatus == .ok || copilotStatus == .alreadySignedIn ||
- copilotStatus == .notAuthorized
- {
- Button("Sign Out") { signOut() }
- }
- if isRunningAction {
- ActivityIndicatorView()
- }
- }
- .buttonStyle(.copilot)
- .opacity(isRunningAction ? 0.8 : 1)
- .disabled(isRunningAction)
- }
- Spacer()
- }.overlay(alignment: .topTrailing) {
- if let message {
- Text(message)
- .padding(.horizontal, 4)
- .padding(.vertical, 2)
- .background(
- RoundedRectangle(cornerRadius: 4)
- .fill(Color.red)
- )
- }
- }
- }.onAppear {
- if isPreview { return }
- checkStatus()
- }
- }
-
- func checkStatus() {
- Task {
- isRunningAction = true
- defer { isRunningAction = false }
- do {
- let service = try getService()
- xpcServiceVersion = try await service.getXPCServiceVersion().version
- copilotStatus = try await service.checkStatus()
- version = try await service.getVersion()
- message = shouldRestartXPCService
- ? "Please restart XPC Service to update it to the latest version."
- : nil
- isRunningAction = false
- } catch {
- message = error.localizedDescription
- }
- }
- }
-
- func signIn() {
- Task {
- isRunningAction = true
- defer { isRunningAction = false }
- do {
- let service = try getService()
- let (uri, userCode) = try await service.signInInitiate()
- self.userCode = userCode
- guard let url = URL(string: uri) else {
- message = "Verification URI is incorrect."
- return
- }
- let pasteboard = NSPasteboard.general
- pasteboard.declareTypes([NSPasteboard.PasteboardType.string], owner: nil)
- pasteboard.setString(userCode, forType: NSPasteboard.PasteboardType.string)
- message = "Usercode \(userCode) already copied!"
- openURL(url)
- isUserCodeCopiedAlertPresented = true
- } catch {
- message = error.localizedDescription
- }
- }
- }
-
- func confirmSignIn() {
- Task {
- isRunningAction = true
- defer { isRunningAction = false }
- do {
- let service = try getService()
- guard let userCode else {
- message = "Usercode is empty."
- return
- }
- let (username, status) = try await service.signInConfirm(userCode: userCode)
- self.username = username
- copilotStatus = status
- } catch {
- message = error.localizedDescription
- }
- }
- }
-
- func signOut() {
- Task {
- isRunningAction = true
- defer { isRunningAction = false }
- do {
- let service = try getService()
- copilotStatus = try await service.signOut()
- } catch {
- message = error.localizedDescription
- }
- }
- }
-}
-
-struct ActivityIndicatorView: NSViewRepresentable {
- func makeNSView(context _: Context) -> NSProgressIndicator {
- let progressIndicator = NSProgressIndicator()
- progressIndicator.style = .spinning
- progressIndicator.appearance = NSAppearance(named: .vibrantLight)
- progressIndicator.controlSize = .small
- progressIndicator.startAnimation(nil)
- return progressIndicator
- }
-
- func updateNSView(_: NSProgressIndicator, context _: Context) {
- // No-op
- }
-}
-
-struct CopilotView_Previews: PreviewProvider {
- static var previews: some View {
- VStack(alignment: .leading, spacing: 8) {
- CopilotView(copilotStatus: .notSignedIn, version: "1.0.0", xpcServiceVersion: "0.0.0")
-
- CopilotView(
- copilotStatus: .alreadySignedIn,
- message: "Error",
- xpcServiceVersion: Bundle.main
- .infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
- )
-
- CopilotView(copilotStatus: .alreadySignedIn, isRunningAction: true)
- }
- .frame(height: 800)
- .padding(.all, 8)
- .background(Color.black)
- }
-}
diff --git a/Copilot for Xcode/Copilot_for_Xcode.entitlements b/Copilot for Xcode/Copilot_for_Xcode.entitlements
index 3c96886a..abefc876 100644
--- a/Copilot for Xcode/Copilot_for_Xcode.entitlements
+++ b/Copilot for Xcode/Copilot_for_Xcode.entitlements
@@ -2,13 +2,19 @@
+ com.apple.security.app-sandbox
+
com.apple.security.application-groups
$(TeamIdentifierPrefix)group.$(BUNDLE_IDENTIFIER_BASE)
- com.apple.security.app-sandbox
-
+ com.apple.security.automation.apple-events
+
com.apple.security.files.user-selected.read-only
+ keychain-access-groups
+
+ $(AppIdentifierPrefix)$(BUNDLE_IDENTIFIER_BASE).Shared
+
diff --git a/Copilot for Xcode/InstructionView.swift b/Copilot for Xcode/InstructionView.swift
deleted file mode 100644
index 071f2deb..00000000
--- a/Copilot for Xcode/InstructionView.swift
+++ /dev/null
@@ -1,58 +0,0 @@
-import SwiftUI
-
-struct InstructionView: View {
- var body: some View {
- Section {
- HStack {
- VStack(alignment: .leading, spacing: 8) {
- // swiftformat:disable indent
-Text("Instruction")
- .font(.title)
- .padding(.bottom, 12)
-Text("Enable Extension")
- .font(.title3)
-Text("""
-1. Install Node. Correctly setup the node path.
-2. Launching the app for the first time, it will automatically setup a launch agent.
-3. Refresh Copilot status (it may fail the first time).
-4. Click `Sign In` to sign into your GitHub account.
-5. After submitting your user code to the verification site, click `Confirm Sign-in` to complete the sign-in.
-6. Go to `System Settings.app > Privacy & Security > Extensions > Xcode Source Editor` , check the **Copilot** checkbox to enable the extension.
-7. Restart Xcode, the Copilot commands should be available in the menu bar.
-""")
-
-Text("Granting Permissions")
- .font(.title3)
-Text("""
-The app needs at least **Accessibility API** permissions to work. If you are using real-time suggestions, please also enabling **Input Monitoring**..
-
-please visit the [project's GitHub page](https://github.com/intitni/CopilotForXcode#granting-permissions-to-the-app) for instructions.
-""")
-
-Text("Disable Extension")
- .font(.title3)
-
-Text("""
-1. Optionally sign out of GitHub Copilot.
-2. Click `Remove Launch Agent`.
-""")
-
-Text(
- "For detailed instructions, please visit the [project's GitHub page](https://github.com/intitni/CopilotForXcode)."
-)
- // swiftformat:enable indent
- Spacer()
- }
- Spacer()
- }
- }
- }
-}
-
-struct InstructionView_Preview: PreviewProvider {
- static var previews: some View {
- InstructionView()
- .background(.black)
- .frame(height: 600)
- }
-}
diff --git a/Copilot for Xcode/LaunchAgentView.swift b/Copilot for Xcode/LaunchAgentView.swift
deleted file mode 100644
index d5a48e81..00000000
--- a/Copilot for Xcode/LaunchAgentView.swift
+++ /dev/null
@@ -1,117 +0,0 @@
-import LaunchAgentManager
-import SwiftUI
-import XPCShared
-
-struct LaunchAgentView: View {
- @State var errorMessage: String?
- @State var isDidRemoveLaunchAgentAlertPresented = false
- @State var isDidSetupLaunchAgentAlertPresented = false
- @State var isDidRestartLaunchAgentAlertPresented = false
- @AppStorage(SettingsKey.nodePath, store: .shared) var nodePath: String = ""
-
- var body: some View {
- Section {
- VStack(alignment: .leading, spacing: 8) {
- HStack {
- Button(action: {
- Task {
- do {
- try await LaunchAgentManager().setupLaunchAgent()
- isDidSetupLaunchAgentAlertPresented = true
- } catch {
- errorMessage = error.localizedDescription
- }
- }
- }) {
- 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 {
- errorMessage = error.localizedDescription
- }
- }
- }) {
- 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 {
- errorMessage = error.localizedDescription
- }
- }
- }) {
- Text("Reload Launch Agent")
- }.alert(isPresented: $isDidRestartLaunchAgentAlertPresented) {
- .init(
- title: Text("Launch Agent Reloaded"),
- dismissButton: .default(Text("OK"))
- )
- }
-
- Spacer()
- .alert(isPresented: .init(
- get: { errorMessage != nil },
- set: { _ in errorMessage = nil }
- )) {
- .init(
- title: Text("Failed"),
- message: Text(errorMessage ?? "Unknown Error"),
- dismissButton: .default(Text("OK"))
- )
- }
- }
-
- HStack {
- Text("Path to Node: ")
- TextField("node", text: $nodePath)
- .textFieldStyle(.copilot)
- }
- }
- }
- .buttonStyle(.copilot)
- .onAppear {
- #if DEBUG
- // do not auto install on debug build
- #else
- Task {
- do {
- try await LaunchAgentManager().setupLaunchAgentForTheFirstTimeIfNeeded()
- } catch {
- errorMessage = error.localizedDescription
- }
- }
- #endif
- }
- }
-}
-
-struct LaunchAgentView_Preview: PreviewProvider {
- static var previews: some View {
- LaunchAgentView()
- .background(.black)
- }
-}
diff --git a/Copilot for Xcode/Section.swift b/Copilot for Xcode/Section.swift
deleted file mode 100644
index fb5202f6..00000000
--- a/Copilot for Xcode/Section.swift
+++ /dev/null
@@ -1,35 +0,0 @@
-import SwiftUI
-
-struct Section: View {
- @ViewBuilder var content: () -> Content
- var body: some View {
- Group {
- content()
- }
- .foregroundColor(.white)
- .padding(.all, 12)
- .background(
- RoundedRectangle(cornerRadius: 8, style: .continuous)
- .stroke(
- Color.white.opacity(0.3),
- style: .init(lineWidth: 1)
- )
- .background(.clear)
- )
- }
-}
-
-struct Section_Preview: PreviewProvider {
- static var previews: some View {
- Group {
- Section {
- VStack {
- Text("Hello")
- Text("World")
- }
- }
- }
- .padding(.all, 30)
- .background(Color("BackgroundColor"))
- }
-}
diff --git a/Copilot for Xcode/SettingsView.swift b/Copilot for Xcode/SettingsView.swift
deleted file mode 100644
index eb918c27..00000000
--- a/Copilot for Xcode/SettingsView.swift
+++ /dev/null
@@ -1,56 +0,0 @@
-import LaunchAgentManager
-import SwiftUI
-import XPCShared
-
-struct SettingsView: View {
- @AppStorage(SettingsKey.quitXPCServiceOnXcodeAndAppQuit, store: .shared)
- var quitXPCServiceOnXcodeAndAppQuit: Bool = false
- @AppStorage(SettingsKey.realtimeSuggestionToggle, store: .shared)
- var realtimeSuggestionToggle: Bool = false
- @AppStorage(SettingsKey.realtimeSuggestionDebounce, store: .shared)
- var realtimeSuggestionDebounce: Double = 0.7
- @State var editingRealtimeSuggestionDebounce: Double = UserDefaults.shared
- .value(forKey: SettingsKey.realtimeSuggestionDebounce) as? Double ?? 0.7
-
- var body: some View {
- Section {
- Form {
- Toggle(isOn: $quitXPCServiceOnXcodeAndAppQuit) {
- Text("Quit service when Xcode and host app are terminated")
- }
- .toggleStyle(.switch)
- Toggle(isOn: $realtimeSuggestionToggle) {
- Text("Real-time suggestion")
- }
- .toggleStyle(.switch)
-
- HStack {
- Slider(value: $editingRealtimeSuggestionDebounce, in: 0...2, step: 0.1) {
- Text("Real-time suggestion fetch debounce")
- } onEditingChanged: { _ in
- realtimeSuggestionDebounce = editingRealtimeSuggestionDebounce
- }
-
- Text(
- "\(editingRealtimeSuggestionDebounce.formatted(.number.precision(.fractionLength(2))))s"
- )
- .font(.body)
- .monospacedDigit()
- .padding(.vertical, 2)
- .padding(.horizontal, 6)
- .background(
- RoundedRectangle(cornerRadius: 4, style: .continuous)
- .fill(Color.white.opacity(0.2))
- )
- }
- }
- }.buttonStyle(.copilot)
- }
-}
-
-struct SettingsView_Preview: PreviewProvider {
- static var previews: some View {
- SettingsView()
- .background(.purple)
- }
-}
diff --git a/Copilot for Xcode/Styles.swift b/Copilot for Xcode/Styles.swift
deleted file mode 100644
index 54c7a52c..00000000
--- a/Copilot for Xcode/Styles.swift
+++ /dev/null
@@ -1,64 +0,0 @@
-import SwiftUI
-
-struct CopilotButtonStyle: ButtonStyle {
- func makeBody(configuration: Configuration) -> some View {
- configuration.label
- .padding(.vertical, 4)
- .padding(.horizontal, 8)
- .foregroundColor(.white)
- .background(
- RoundedRectangle(cornerRadius: 4, style: .continuous)
- .fill(
- configuration.isPressed
- ? Color("ButtonBackgroundColorPressed")
- : Color("ButtonBackgroundColorDefault")
- )
- .animation(.easeOut(duration: 0.1), value: configuration.isPressed)
- )
- .overlay {
- RoundedRectangle(cornerRadius: 4, style: .continuous)
- .stroke(Color.white.opacity(0.2), style: .init(lineWidth: 1))
- }
- }
-}
-
-extension ButtonStyle where Self == CopilotButtonStyle {
- static var copilot: CopilotButtonStyle { CopilotButtonStyle() }
-}
-
-struct CopilotTextFieldStyle: TextFieldStyle {
- func _body(configuration: TextField<_Label>) -> some View {
- configuration
- .colorScheme(.dark)
- .textFieldStyle(.plain)
- .foregroundColor(.white)
- .padding(.vertical, 4)
- .padding(.horizontal, 8)
- .background(
- RoundedRectangle(cornerRadius: 4, style: .continuous)
- .fill(.white.opacity(0.2))
- )
- .overlay {
- RoundedRectangle(cornerRadius: 4, style: .continuous)
- .stroke(.white.opacity(0.2), style: .init(lineWidth: 1))
- }
- }
-}
-
-extension TextFieldStyle where Self == CopilotTextFieldStyle {
- static var copilot: CopilotTextFieldStyle { CopilotTextFieldStyle() }
-}
-
-struct CopilotStyle_Previews: PreviewProvider {
- static var previews: some View {
- VStack(alignment: .leading, spacing: 8) {
- Button("Button") {}
- .buttonStyle(.copilot)
-
- TextField("title", text: .constant("Placeholder"))
- .textFieldStyle(.copilot)
- }
- .padding(.all, 8)
- .background(Color.black)
- }
-}
diff --git a/Copilot-for-Xcode-Info.plist b/Copilot-for-Xcode-Info.plist
index 2c3dad3c..9f9fdd6e 100644
--- a/Copilot-for-Xcode-Info.plist
+++ b/Copilot-for-Xcode-Info.plist
@@ -2,9 +2,28 @@
+ APPLICATION_SUPPORT_FOLDER
+ $(APPLICATION_SUPPORT_FOLDER)
+ APP_ID_PREFIX
+ $(AppIdentifierPrefix)
BUNDLE_IDENTIFIER_BASE
$(BUNDLE_IDENTIFIER_BASE)
EXTENSION_BUNDLE_NAME
$(EXTENSION_BUNDLE_NAME)
+ HOST_APP_NAME
+ $(HOST_APP_NAME)
+ NSAppTransportSecurity
+
+ NSAllowsArbitraryLoads
+
+
+ SUEnableJavaScript
+ YES
+ SUFeedURL
+ $(SPARKLE_FEED_URL)
+ SUPublicEDKey
+ $(SPARKLE_PUBLIC_KEY)
+ TEAM_ID_PREFIX
+ $(TeamIdentifierPrefix)
diff --git a/Core/.swiftpm/xcode/xcshareddata/xcschemes/ServiceTests.xcscheme b/Core/.swiftpm/xcode/xcshareddata/xcschemes/Client.xcscheme
similarity index 59%
rename from Core/.swiftpm/xcode/xcshareddata/xcschemes/ServiceTests.xcscheme
rename to Core/.swiftpm/xcode/xcshareddata/xcschemes/Client.xcscheme
index f548b29e..0deca224 100644
--- a/Core/.swiftpm/xcode/xcshareddata/xcschemes/ServiceTests.xcscheme
+++ b/Core/.swiftpm/xcode/xcshareddata/xcschemes/Client.xcscheme
@@ -1,28 +1,34 @@
+ LastUpgradeVersion = "1540"
+ version = "1.7">
+ buildImplicitDependencies = "YES"
+ buildArchitectures = "Automatic">
+
+
+
+
+
+
-
-
-
-
-
-
+ shouldUseLaunchSchemeArgsEnv = "YES"
+ shouldAutocreateTestPlan = "YES">
+
+
+
+
diff --git a/Core/.swiftpm/xcode/xcshareddata/xcschemes/HostApp.xcscheme b/Core/.swiftpm/xcode/xcshareddata/xcschemes/HostApp.xcscheme
new file mode 100644
index 00000000..25654d7d
--- /dev/null
+++ b/Core/.swiftpm/xcode/xcshareddata/xcschemes/HostApp.xcscheme
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 {
+ 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/CGEventObserver/CGEventObserver.swift b/Core/Sources/CGEventObserver/CGEventObserver.swift
deleted file mode 100644
index 09e223f4..00000000
--- a/Core/Sources/CGEventObserver/CGEventObserver.swift
+++ /dev/null
@@ -1,105 +0,0 @@
-import Cocoa
-import Foundation
-import os.log
-
-public protocol CGEventObserverType {
- @discardableResult
- func activateIfPossible() -> Bool
- func deactivate()
- var stream: AsyncStream { get }
- var isEnabled: Bool { get }
-}
-
-public final class CGEventObserver: CGEventObserverType {
- public let stream: AsyncStream
- public var isEnabled: Bool { port != nil }
-
- private var continuation: AsyncStream.Continuation
- private var port: CFMachPort?
- private let eventsOfInterest: Set
- private let tapLocation: CGEventTapLocation = .cghidEventTap
- private let tapPlacement: CGEventTapPlacement = .tailAppendEventTap
- private let tapOptions: CGEventTapOptions = .listenOnly
- private var retryTask: Task?
-
- deinit {
- continuation.finish()
- CFMachPortInvalidate(port)
- }
-
- public init(eventsOfInterest: Set) {
- self.eventsOfInterest = eventsOfInterest
- var continuation: AsyncStream.Continuation!
- stream = AsyncStream { c in
- continuation = c
- }
- self.continuation = continuation
- }
-
- public func deactivate() {
- retryTask?.cancel()
- retryTask = nil
- guard let port else { return }
- os_log(.info, "CGEventObserver deactivated.")
- CFMachPortInvalidate(port)
- self.port = nil
- }
-
- @discardableResult
- public func activateIfPossible() -> Bool {
- guard AXIsProcessTrusted() else { return false }
- guard port == nil else { return true }
-
- let eoi = UInt64(eventsOfInterest.reduce(into: 0) { $0 |= 1 << $1.rawValue })
-
- func callback(
- tapProxy _: CGEventTapProxy,
- eventType: CGEventType,
- event: CGEvent,
- continuationPointer: UnsafeMutableRawPointer?
- ) -> Unmanaged? {
- guard AXIsProcessTrusted() else {
- return .passRetained(event)
- }
-
- if eventType == .tapDisabledByTimeout || eventType == .tapDisabledByUserInput {
- return .passRetained(event)
- }
-
- if let continuation = continuationPointer?
- .assumingMemoryBound(to: AsyncStream.Continuation.self)
- {
- continuation.pointee.yield(event)
- }
-
- return .passRetained(event)
- }
-
- let tapLocation = tapLocation
- let tapPlacement = tapPlacement
- let tapOptions = tapOptions
-
- guard let port = withUnsafeMutablePointer(to: &continuation, { pointer in
- CGEvent.tapCreate(
- tap: tapLocation,
- place: tapPlacement,
- options: tapOptions,
- eventsOfInterest: eoi,
- callback: callback,
- userInfo: pointer
- )
- }) else {
- retryTask = Task {
- try? await Task.sleep(nanoseconds: 2_000_000_000)
- try Task.checkCancellation()
- activateIfPossible()
- }
- return false
- }
- self.port = port
- let runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, port, 0)
- CFRunLoopAddSource(RunLoop.main.getCFRunLoop(), runLoopSource, .commonModes)
- os_log(.info, "CGEventObserver activated.")
- return true
- }
-}
diff --git a/Core/Sources/ChatContextCollectors/SystemInfoChatContextCollector/SystemInfoChatContextCollector.swift b/Core/Sources/ChatContextCollectors/SystemInfoChatContextCollector/SystemInfoChatContextCollector.swift
new file mode 100644
index 00000000..9686ca85
--- /dev/null
+++ b/Core/Sources/ChatContextCollectors/SystemInfoChatContextCollector/SystemInfoChatContextCollector.swift
@@ -0,0 +1,33 @@
+import ChatContextCollector
+import Foundation
+import OpenAIService
+
+public final class SystemInfoChatContextCollector: ChatContextCollector {
+ static let dateFormatter: DateFormatter = {
+ let formatter = DateFormatter()
+ formatter.dateFormat = "EEEE, yyyy-MM-dd HH:mm:ssZ"
+ return formatter
+ }()
+
+ public init() {}
+
+ public func generateContext(
+ history: [ChatMessage],
+ scopes: Set,
+ content: String,
+ configuration: ChatGPTConfiguration
+ ) -> ChatContext {
+ return .init(
+ systemPrompt: """
+ ## System Info
+
+ Current Time: \(
+ Self.dateFormatter.string(from: Date())
+ ) (You can use it to calculate time in another time zone)
+ """,
+ retrievedContent: [],
+ functions: []
+ )
+ }
+}
+
diff --git a/Core/Sources/ChatContextCollectors/WebChatContextCollector/QueryWebsiteFunction.swift b/Core/Sources/ChatContextCollectors/WebChatContextCollector/QueryWebsiteFunction.swift
new file mode 100644
index 00000000..0620123c
--- /dev/null
+++ b/Core/Sources/ChatContextCollectors/WebChatContextCollector/QueryWebsiteFunction.swift
@@ -0,0 +1,137 @@
+import ChatBasic
+import Foundation
+import LangChain
+import OpenAIService
+import Preferences
+
+struct QueryWebsiteFunction: ChatGPTFunction {
+ struct Arguments: Codable {
+ var query: String
+ var urls: [String]
+ }
+
+ struct Result: ChatGPTFunctionResult {
+ var answers: [String]
+
+ var botReadableContent: String {
+ return answers.joined(separator: "\n")
+ }
+
+ var userReadableContent: ChatGPTFunctionResultUserReadableContent {
+ .text(botReadableContent)
+ }
+ }
+
+ var name: String {
+ "queryWebsite"
+ }
+
+ var description: String {
+ "Useful for when you need to answer a question using information from a website."
+ }
+
+ var argumentSchema: JSONSchemaValue {
+ return [
+ .type: "object",
+ .properties: [
+ "query": [
+ .type: "string",
+ .description: "things you want to know about the website",
+ ],
+ "urls": [
+ .type: "array",
+ .description: "urls of the website, you can use urls appearing in the conversation",
+ .items: [
+ .type: "string",
+ ],
+ ],
+ ],
+ .required: ["query", "urls"],
+ ]
+ }
+
+ func prepare(reportProgress: @escaping (String) async -> Void) async {
+ await reportProgress("Reading..")
+ }
+
+ func call(
+ arguments: Arguments,
+ reportProgress: @escaping (String) async -> Void
+ ) async throws -> Result {
+ do {
+ 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: storeIdentifier,
+ dimensions: dimensions
+ ) {
+ await reportProgress("Getting relevant information..")
+ let qa = QAInformationRetrievalChain(
+ vectorStore: database,
+ embedding: embedding
+ )
+ return try await qa.call(.init(arguments.query)).information
+ }
+ let loader = WebLoader(urls: [url])
+ let documents = try await loader.load()
+ await reportProgress("Processing \(url)..")
+ // 2. split the content
+ let splitter = RecursiveCharacterTextSplitter(
+ 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: storeIdentifier,
+ dimensions: dimensions
+ )
+ try await database.set(embeddedDocuments)
+ // 4. generate answer
+ await reportProgress("Getting relevant information..")
+ let qa = QAInformationRetrievalChain(
+ vectorStore: database,
+ embedding: embedding
+ )
+ let result = try await qa.call(.init(arguments.query))
+ return result.information
+ }
+ }
+
+ var all = [String]()
+ for try await result in group {
+ all.append(result)
+ }
+ await reportProgress("""
+ Finish reading websites.
+ \(
+ arguments.urls
+ .map { "- [\($0)](\($0))" }
+ .joined(separator: "\n")
+ )
+ """)
+
+ return all
+ }
+
+ return .init(answers: result)
+ } catch {
+ await reportProgress("Failed reading websites.")
+ throw error
+ }
+ }
+}
+
diff --git a/Core/Sources/ChatContextCollectors/WebChatContextCollector/SearchFunction.swift b/Core/Sources/ChatContextCollectors/WebChatContextCollector/SearchFunction.swift
new file mode 100644
index 00000000..60a5504e
--- /dev/null
+++ b/Core/Sources/ChatContextCollectors/WebChatContextCollector/SearchFunction.swift
@@ -0,0 +1,99 @@
+import ChatBasic
+import Foundation
+import OpenAIService
+import Preferences
+import WebSearchService
+
+struct SearchFunction: ChatGPTFunction {
+ static let dateFormatter = {
+ let it = DateFormatter()
+ it.dateFormat = "yyyy-MM-dd"
+ return it
+ }()
+
+ struct Arguments: Codable {
+ var query: String
+ var freshness: String?
+ }
+
+ struct Result: ChatGPTFunctionResult {
+ var result: WebSearchResult
+
+ var botReadableContent: String {
+ result.webPages.enumerated().map {
+ let (index, page) = $0
+ return """
+ \(index + 1). \(page.title) \(page.urlString)
+ \(page.snippet)
+ """
+ }.joined(separator: "\n")
+ }
+
+ var userReadableContent: ChatGPTFunctionResultUserReadableContent {
+ .text(botReadableContent)
+ }
+ }
+
+ let maxTokens: Int
+
+ var name: String {
+ "searchWeb"
+ }
+
+ var description: String {
+ "Useful for when you need to answer questions about latest information."
+ }
+
+ var argumentSchema: JSONSchemaValue {
+ let today = Self.dateFormatter.string(from: Date())
+ return [
+ .type: "object",
+ .properties: [
+ "query": [
+ .type: "string",
+ .description: "the search query",
+ ],
+ "freshness": [
+ .type: "string",
+ .description: .string(
+ "limit the search result to a specific range, use only when I ask the question about current events. Today is \(today). Format: yyyy-MM-dd..yyyy-MM-dd"
+ ),
+ .examples: ["1919-10-20..1988-10-20"],
+ ],
+ ],
+ .required: ["query"],
+ ]
+ }
+
+ func prepare(reportProgress: @escaping ReportProgress) async {
+ await reportProgress("Searching..")
+ }
+
+ func call(
+ arguments: Arguments,
+ reportProgress: @escaping ReportProgress
+ ) async throws -> Result {
+ await reportProgress("Searching \(arguments.query)")
+
+ do {
+ let search = WebSearchService(provider: .userPreferred)
+
+ let result = try await search.search(query: arguments.query)
+
+ await reportProgress("""
+ Finish searching \(arguments.query)
+ \(
+ result.webPages
+ .map { "- [\($0.title)](\($0.urlString))" }
+ .joined(separator: "\n")
+ )
+ """)
+
+ return .init(result: result)
+ } catch {
+ await reportProgress("Failed searching: \(error.localizedDescription)")
+ throw error
+ }
+ }
+}
+
diff --git a/Core/Sources/ChatContextCollectors/WebChatContextCollector/WebChatContextCollector.swift b/Core/Sources/ChatContextCollectors/WebChatContextCollector/WebChatContextCollector.swift
new file mode 100644
index 00000000..848ca0fa
--- /dev/null
+++ b/Core/Sources/ChatContextCollectors/WebChatContextCollector/WebChatContextCollector.swift
@@ -0,0 +1,59 @@
+import ChatBasic
+import ChatContextCollector
+import Foundation
+import OpenAIService
+
+public final class WebChatContextCollector: ChatContextCollector {
+ var recentLinks = [String]()
+
+ public init() {}
+
+ public func generateContext(
+ history: [ChatMessage],
+ scopes: Set,
+ content: String,
+ configuration: ChatGPTConfiguration
+ ) -> ChatContext {
+ guard scopes.contains(.web) else { return .empty }
+ let links = Self.detectLinks(from: history) + Self.detectLinks(from: content)
+ let functions: [(any ChatGPTFunction)?] = [
+ SearchFunction(maxTokens: configuration.maxTokens),
+ // allow this function only when there is a link in the memory.
+ links.isEmpty ? nil : QueryWebsiteFunction(),
+ ]
+ return .init(
+ systemPrompt: "You prefer to answer questions with latest content on the internet.",
+ retrievedContent: [],
+ functions: functions.compactMap { $0 }
+ )
+ }
+}
+
+extension WebChatContextCollector {
+ static func detectLinks(from messages: [ChatMessage]) -> [String] {
+ return messages.lazy
+ .compactMap {
+ $0.content ?? $0.toolCalls?.map(\.function.arguments).joined(separator: " ") ?? ""
+ }
+ .map(detectLinks(from:))
+ .flatMap { $0 }
+ }
+
+ static func detectLinks(from content: String) -> [String] {
+ var links = [String]()
+ let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
+ let matches = detector?.matches(
+ in: content,
+ options: [],
+ range: NSRange(content.startIndex..., in: content)
+ )
+
+ for match in matches ?? [] {
+ guard let range = Range(match.range, in: content) else { continue }
+ let url = content[range]
+ links.append(String(url))
+ }
+ return links
+ }
+}
+
diff --git a/Core/Sources/ChatGPTChatTab/Chat.swift b/Core/Sources/ChatGPTChatTab/Chat.swift
new file mode 100644
index 00000000..28443876
--- /dev/null
+++ b/Core/Sources/ChatGPTChatTab/Chat.swift
@@ -0,0 +1,555 @@
+import AppKit
+import ChatBasic
+import ChatService
+import ComposableArchitecture
+import Foundation
+import MarkdownUI
+import OpenAIService
+import Preferences
+import Terminal
+
+public struct DisplayedChatMessage: Equatable {
+ public enum Role: Equatable {
+ case user
+ case assistant
+ case tool
+ case ignored
+ }
+
+ public struct Reference: Equatable {
+ public typealias Kind = ChatMessage.Reference.Kind
+
+ public var title: String
+ public var subtitle: String
+ public var uri: String
+ public var startLine: Int?
+ public var kind: Kind
+
+ public init(
+ title: String,
+ subtitle: String,
+ uri: String,
+ startLine: Int?,
+ kind: Kind
+ ) {
+ self.title = title
+ self.subtitle = subtitle
+ self.uri = uri
+ self.startLine = startLine
+ self.kind = kind
+ }
+ }
+
+ public var id: String
+ public var role: Role
+ public var text: String
+ public var markdownContent: MarkdownContent
+ public var references: [Reference] = []
+
+ public init(id: String, role: Role, text: String, references: [Reference]) {
+ self.id = id
+ self.role = role
+ self.text = text
+ markdownContent = .init(text)
+ self.references = references
+ }
+}
+
+private var isPreview: Bool {
+ ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
+}
+
+@Reducer
+struct Chat {
+ public typealias MessageID = String
+
+ @ObservableState
+ struct State: Equatable {
+ var title: String = "Chat"
+ var typedMessage = ""
+ var history: [DisplayedChatMessage] = []
+ var isReceivingMessage = false
+ var chatMenu = ChatMenu.State()
+ var focusedField: Field?
+ var isEnabled = true
+ var isPinnedToBottom = true
+
+ enum Field: String, Hashable {
+ case textField
+ }
+ }
+
+ enum Action: Equatable, BindableAction {
+ case binding(BindingAction)
+
+ case appear
+ case refresh
+ case setIsEnabled(Bool)
+ case sendButtonTapped
+ case returnButtonTapped
+ case stopRespondingButtonTapped
+ case clearButtonTap
+ case deleteMessageButtonTapped(MessageID)
+ case resendMessageButtonTapped(MessageID)
+ case setAsExtraPromptButtonTapped(MessageID)
+ case manuallyScrolledUp
+ case scrollToBottomButtonTapped
+ case focusOnTextField
+ case referenceClicked(DisplayedChatMessage.Reference)
+
+ case observeChatService
+ case observeHistoryChange
+ case observeIsReceivingMessageChange
+ case observeSystemPromptChange
+ case observeExtraSystemPromptChange
+ case observeDefaultScopesChange
+
+ case historyChanged
+ case isReceivingMessageChanged
+ case systemPromptChanged
+ case extraSystemPromptChanged
+ case defaultScopesChanged
+
+ case chatMenu(ChatMenu.Action)
+ }
+
+ let service: ChatService
+ let id = UUID()
+
+ enum CancelID: Hashable {
+ case observeHistoryChange(UUID)
+ case observeIsReceivingMessageChange(UUID)
+ case observeSystemPromptChange(UUID)
+ case observeExtraSystemPromptChange(UUID)
+ case observeDefaultScopesChange(UUID)
+ case sendMessage(UUID)
+ }
+
+ var body: some ReducerOf {
+ BindingReducer()
+
+ Scope(state: \.chatMenu, action: \.chatMenu) {
+ ChatMenu(service: service)
+ }
+
+ Reduce { state, action in
+ switch action {
+ case .appear:
+ return .run { send in
+ if isPreview { return }
+ await send(.observeChatService)
+ await send(.historyChanged)
+ await send(.isReceivingMessageChanged)
+ await send(.systemPromptChanged)
+ await send(.extraSystemPromptChanged)
+ await send(.focusOnTextField)
+ await send(.refresh)
+ }
+
+ case .refresh:
+ return .run { send in
+ await send(.chatMenu(.refresh))
+ }
+
+ case let .setIsEnabled(isEnabled):
+ state.isEnabled = isEnabled
+ return .none
+
+ case .sendButtonTapped:
+ guard !state.typedMessage.isEmpty else { return .none }
+ let message = state.typedMessage
+ state.typedMessage = ""
+ return .run { _ in
+ try await service.send(content: message)
+ }.cancellable(id: CancelID.sendMessage(id))
+
+ case .returnButtonTapped:
+ state.typedMessage += "\n"
+ return .none
+
+ case .stopRespondingButtonTapped:
+ return .merge(
+ .run { _ in
+ await service.stopReceivingMessage()
+ },
+ .cancel(id: CancelID.sendMessage(id))
+ )
+
+ case .clearButtonTap:
+ return .run { _ in
+ await service.clearHistory()
+ }
+
+ case let .deleteMessageButtonTapped(id):
+ return .run { _ in
+ await service.deleteMessage(id: id)
+ }
+
+ case let .resendMessageButtonTapped(id):
+ return .run { _ in
+ try await service.resendMessage(id: id)
+ }
+
+ case let .setAsExtraPromptButtonTapped(id):
+ return .run { _ in
+ await service.setMessageAsExtraPrompt(id: id)
+ }
+
+ case let .referenceClicked(reference):
+ let fileURL = URL(fileURLWithPath: reference.uri)
+ return .run { _ in
+ if FileManager.default.fileExists(atPath: fileURL.path) {
+ let terminal = Terminal()
+ do {
+ _ = try await terminal.runCommand(
+ "/bin/bash",
+ arguments: [
+ "-c",
+ "xed -l \(reference.startLine ?? 0) ${TARGET_FILE}",
+ ],
+ environment: ["TARGET_FILE": reference.uri]
+ )
+ } catch {
+ print(error)
+ }
+ } else if let url = URL(string: reference.uri), url.scheme != nil {
+ NSWorkspace.shared.open(url)
+ }
+ }
+
+ case .manuallyScrolledUp:
+ state.isPinnedToBottom = false
+ return .none
+
+ case .scrollToBottomButtonTapped:
+ state.isPinnedToBottom = true
+ return .none
+
+ case .focusOnTextField:
+ state.focusedField = .textField
+ return .none
+
+ case .observeChatService:
+ return .run { send in
+ await send(.observeHistoryChange)
+ await send(.observeIsReceivingMessageChange)
+ await send(.observeSystemPromptChange)
+ await send(.observeExtraSystemPromptChange)
+ await send(.observeDefaultScopesChange)
+ }
+
+ case .observeHistoryChange:
+ return .run { send in
+ let stream = AsyncStream { continuation in
+ let cancellable = service.$chatHistory.sink { _ in
+ continuation.yield()
+ }
+ continuation.onTermination = { _ in
+ cancellable.cancel()
+ }
+ }
+ let debouncedHistoryChange = TimedDebounceFunction(duration: 0.2) {
+ await send(.historyChanged)
+ }
+
+ for await _ in stream {
+ await debouncedHistoryChange()
+ }
+ }.cancellable(id: CancelID.observeHistoryChange(id), cancelInFlight: true)
+
+ case .observeIsReceivingMessageChange:
+ return .run { send in
+ let stream = AsyncStream { continuation in
+ let cancellable = service.$isReceivingMessage
+ .sink { _ in
+ continuation.yield()
+ }
+ continuation.onTermination = { _ in
+ cancellable.cancel()
+ }
+ }
+ for await _ in stream {
+ await send(.isReceivingMessageChanged)
+ }
+ }.cancellable(
+ id: CancelID.observeIsReceivingMessageChange(id),
+ cancelInFlight: true
+ )
+
+ case .observeSystemPromptChange:
+ return .run { send in
+ let stream = AsyncStream { continuation in
+ let cancellable = service.$systemPrompt.sink { _ in
+ continuation.yield()
+ }
+ continuation.onTermination = { _ in
+ cancellable.cancel()
+ }
+ }
+ for await _ in stream {
+ await send(.systemPromptChanged)
+ }
+ }.cancellable(id: CancelID.observeSystemPromptChange(id), cancelInFlight: true)
+
+ case .observeExtraSystemPromptChange:
+ return .run { send in
+ let stream = AsyncStream { continuation in
+ let cancellable = service.$extraSystemPrompt
+ .sink { _ in
+ continuation.yield()
+ }
+ continuation.onTermination = { _ in
+ cancellable.cancel()
+ }
+ }
+ for await _ in stream {
+ await send(.extraSystemPromptChanged)
+ }
+ }.cancellable(id: CancelID.observeExtraSystemPromptChange(id), cancelInFlight: true)
+
+ case .observeDefaultScopesChange:
+ return .run { send in
+ let stream = AsyncStream { continuation in
+ let cancellable = service.$defaultScopes
+ .sink { _ in
+ continuation.yield()
+ }
+ continuation.onTermination = { _ in
+ cancellable.cancel()
+ }
+ }
+ for await _ in stream {
+ await send(.defaultScopesChanged)
+ }
+ }.cancellable(id: CancelID.observeDefaultScopesChange(id), cancelInFlight: true)
+
+ case .historyChanged:
+ state.history = service.chatHistory.flatMap { message in
+ var all = [DisplayedChatMessage]()
+ all.append(.init(
+ id: message.id,
+ role: {
+ switch message.role {
+ case .system: return .ignored
+ case .user: return .user
+ case .assistant:
+ if let text = message.summary ?? message.content,
+ !text.isEmpty
+ {
+ return .assistant
+ }
+ return .ignored
+ }
+ }(),
+ text: message.summary ?? message.content ?? "",
+ references: message.references.map(convertReference)
+ ))
+
+ for call in message.toolCalls ?? [] {
+ all.append(.init(
+ id: message.id + call.id,
+ role: .tool,
+ text: call.response.summary ?? call.response.content,
+ references: []
+ ))
+ }
+
+ return all
+ }
+
+ state.title = {
+ let defaultTitle = "Chat"
+ guard let lastMessageText = state.history
+ .filter({ $0.role == .assistant || $0.role == .user })
+ .last?
+ .text else { return defaultTitle }
+ if lastMessageText.isEmpty { return defaultTitle }
+ let trimmed = lastMessageText
+ .trimmingCharacters(in: .punctuationCharacters)
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ if trimmed.starts(with: "```") {
+ return "Code Block"
+ } else {
+ return trimmed
+ }
+ }()
+ return .none
+
+ case .isReceivingMessageChanged:
+ state.isReceivingMessage = service.isReceivingMessage
+ if service.isReceivingMessage {
+ state.isPinnedToBottom = true
+ }
+ return .none
+
+ case .systemPromptChanged:
+ state.chatMenu.systemPrompt = service.systemPrompt
+ return .none
+
+ case .extraSystemPromptChanged:
+ state.chatMenu.extraSystemPrompt = service.extraSystemPrompt
+ return .none
+
+ case .defaultScopesChanged:
+ state.chatMenu.defaultScopes = service.defaultScopes
+ return .none
+
+ case .binding:
+ return .none
+
+ case .chatMenu:
+ return .none
+ }
+ }
+ }
+}
+
+@Reducer
+struct ChatMenu {
+ @ObservableState
+ struct State: Equatable {
+ var systemPrompt: String = ""
+ var extraSystemPrompt: String = ""
+ var temperatureOverride: Double? = nil
+ var chatModelIdOverride: String? = nil
+ var defaultScopes: Set = []
+ }
+
+ enum Action: Equatable {
+ case appear
+ case refresh
+ case resetPromptButtonTapped
+ case temperatureOverrideSelected(Double?)
+ case chatModelIdOverrideSelected(String?)
+ case customCommandButtonTapped(CustomCommand)
+ case resetDefaultScopesButtonTapped
+ case toggleScope(ChatService.Scope)
+ }
+
+ let service: ChatService
+
+ var body: some ReducerOf {
+ Reduce { state, action in
+ switch action {
+ case .appear:
+ return .run {
+ await $0(.refresh)
+ }
+
+ case .refresh:
+ state.temperatureOverride = service.configuration.overriding.temperature
+ state.chatModelIdOverride = service.configuration.overriding.modelId
+ return .none
+
+ case .resetPromptButtonTapped:
+ return .run { _ in
+ await service.resetPrompt()
+ }
+ case let .temperatureOverrideSelected(temperature):
+ state.temperatureOverride = temperature
+ return .run { _ in
+ service.configuration.overriding.temperature = temperature
+ }
+ case let .chatModelIdOverrideSelected(chatModelId):
+ state.chatModelIdOverride = chatModelId
+ return .run { _ in
+ service.configuration.overriding.modelId = chatModelId
+ }
+ case let .customCommandButtonTapped(command):
+ return .run { _ in
+ try await service.handleCustomCommand(command)
+ }
+
+ case .resetDefaultScopesButtonTapped:
+ return .run { _ in
+ service.resetDefaultScopes()
+ }
+ case let .toggleScope(scope):
+ return .run { _ in
+ service.defaultScopes.formSymmetricDifference([scope])
+ }
+ }
+ }
+ }
+}
+
+private actor TimedDebounceFunction {
+ let duration: TimeInterval
+ let block: () async -> Void
+
+ var task: Task?
+ var lastFireTime: Date = .init(timeIntervalSince1970: 0)
+
+ init(duration: TimeInterval, block: @escaping () async -> Void) {
+ self.duration = duration
+ self.block = block
+ }
+
+ func callAsFunction() async {
+ task?.cancel()
+ if lastFireTime.timeIntervalSinceNow < -duration {
+ await fire()
+ task = nil
+ } else {
+ task = Task.detached { [weak self, duration] in
+ try await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000))
+ await self?.fire()
+ }
+ }
+ }
+
+ func fire() async {
+ lastFireTime = Date()
+ await block()
+ }
+}
+
+private func convertReference(
+ _ reference: ChatMessage.Reference
+) -> DisplayedChatMessage.Reference {
+ .init(
+ title: reference.title,
+ subtitle: {
+ switch reference.kind {
+ case let .symbol(_, uri, _, _):
+ return uri
+ case let .webpage(uri):
+ return uri
+ case let .textFile(uri):
+ return uri
+ case let .other(kind):
+ return kind
+ case .text:
+ return reference.content
+ case .error:
+ return reference.content
+ }
+ }(),
+ uri: {
+ switch reference.kind {
+ case let .symbol(_, uri, _, _):
+ return uri
+ case let .webpage(uri):
+ return uri
+ case let .textFile(uri):
+ return uri
+ case .other:
+ return ""
+ case .text:
+ return ""
+ case .error:
+ return ""
+ }
+ }(),
+ startLine: {
+ switch reference.kind {
+ case let .symbol(_, _, startLine, _):
+ return startLine
+ default:
+ return nil
+ }
+ }(),
+ kind: reference.kind
+ )
+}
+
diff --git a/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift b/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift
new file mode 100644
index 00000000..9114a5dd
--- /dev/null
+++ b/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift
@@ -0,0 +1,206 @@
+import AppKit
+import ChatService
+import ComposableArchitecture
+import SharedUIComponents
+import SwiftUI
+
+struct ChatTabItemView: View {
+ let chat: StoreOf
+
+ var body: some View {
+ WithPerceptionTracking {
+ Text(chat.title)
+ }
+ }
+}
+
+struct ChatContextMenu: View {
+ let store: StoreOf
+ @AppStorage(\.customCommands) var customCommands
+ @AppStorage(\.chatModels) var chatModels
+ @AppStorage(\.defaultChatFeatureChatModelId) var defaultChatModelId
+ @AppStorage(\.chatGPTTemperature) var defaultTemperature
+
+ var body: some View {
+ WithPerceptionTracking {
+ currentSystemPrompt
+ .onAppear { store.send(.appear) }
+ currentExtraSystemPrompt
+ resetPrompt
+
+ Divider()
+
+ chatModel
+ temperature
+ defaultScopes
+
+ Divider()
+
+ customCommandMenu
+ }
+ }
+
+ @ViewBuilder
+ var currentSystemPrompt: some View {
+ Text("System Prompt:")
+ 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:")
+ 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 {
+ Button("Reset System Prompt") {
+ store.send(.resetPromptButtonTapped)
+ }
+ }
+
+ @ViewBuilder
+ var chatModel: some View {
+ let allModels = chatModels + [.init(
+ id: "com.github.copilot",
+ name: "GitHub Copilot Language Server",
+ format: .openAI,
+ info: .init()
+ )]
+
+ Menu("Chat Model") {
+ Button(action: {
+ 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 = 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(allModels, id: \.id) { model in
+ Button(action: {
+ store.send(.chatModelIdOverrideSelected(model.id))
+ }) {
+ HStack {
+ Text(model.name)
+ if model.id == store.chatModelIdOverride {
+ Image(systemName: "checkmark")
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ var temperature: some View {
+ Menu("Temperature") {
+ 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: {
+ store.send(.temperatureOverrideSelected(value))
+ }) {
+ HStack {
+ Text("\(value.formatted(.number.precision(.fractionLength(1))))")
+ if value == store.temperatureOverride {
+ Image(systemName: "checkmark")
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ var defaultScopes: some View {
+ Menu("Default Scopes") {
+ Button(action: {
+ store.send(.resetDefaultScopesButtonTapped)
+ }) {
+ Text("Reset Default Scopes")
+ }
+
+ Divider()
+
+ ForEach(ChatService.Scope.allCases, id: \.rawValue) { value in
+ Button(action: {
+ store.send(.toggleScope(value))
+ }) {
+ HStack {
+ Text("@" + value.rawValue)
+ if store.defaultScopes.contains(value) {
+ Image(systemName: "checkmark")
+ }
+ }
+ }
+ }
+ }
+ }
+
+ var customCommandMenu: some View {
+ Menu("Custom Commands") {
+ ForEach(
+ customCommands.filter {
+ switch $0.feature {
+ case .chatWithSelection, .customChat: return true
+ case .promptToCode: return false
+ case .singleRoundDialog: return false
+ }
+ },
+ id: \.name
+ ) { command in
+ Button(action: {
+ store.send(.customCommandButtonTapped(command))
+ }) {
+ Text(command.name)
+ }
+ }
+ }
+ }
+}
+
diff --git a/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift b/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift
new file mode 100644
index 00000000..ad2c6887
--- /dev/null
+++ b/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift
@@ -0,0 +1,188 @@
+import ChatContextCollector
+import ChatService
+import ChatTab
+import CodableWrappers
+import Combine
+import ComposableArchitecture
+import DebounceFunction
+import Foundation
+import OpenAIService
+import Preferences
+import SwiftUI
+
+/// A chat tab that provides a context aware chat bot, powered by ChatGPT.
+public class ChatGPTChatTab: ChatTab {
+ public static var name: String { "Chat" }
+
+ public let service: ChatService
+ let chat: StoreOf
+ private var cancellable = Set()
+ private var observer = NSObject()
+ private let updateContentDebounce = DebounceRunner(duration: 0.5)
+
+ struct RestorableState: Codable {
+ var history: [OpenAIService.ChatMessage]
+ var configuration: OverridingChatGPTConfiguration.Overriding
+ var systemPrompt: String
+ var extraSystemPrompt: String
+ var defaultScopes: Set?
+ }
+
+ struct Builder: ChatTabBuilder {
+ var title: String
+ var customCommand: CustomCommand?
+ var afterBuild: (ChatGPTChatTab) async -> Void = { _ in }
+
+ func build(store: StoreOf) async -> (any ChatTab)? {
+ let tab = await ChatGPTChatTab(store: store)
+ if let customCommand {
+ try? await tab.service.handleCustomCommand(customCommand)
+ }
+ await afterBuild(tab)
+ return tab
+ }
+ }
+
+ public func buildView() -> any View {
+ ChatPanel(chat: chat)
+ }
+
+ public func buildTabItem() -> any View {
+ ChatTabItemView(chat: chat)
+ }
+
+ public func buildIcon() -> any View {
+ WithPerceptionTracking {
+ if self.chat.isReceivingMessage {
+ Image(systemName: "ellipsis.message")
+ } else {
+ Image(systemName: "message")
+ }
+ }
+ }
+
+ public func buildMenu() -> any View {
+ ChatContextMenu(store: chat.scope(state: \.chatMenu, action: \.chatMenu))
+ }
+
+ public func restorableState() async -> Data {
+ let state = RestorableState(
+ history: await service.memory.history,
+ configuration: service.configuration.overriding,
+ systemPrompt: service.systemPrompt,
+ extraSystemPrompt: service.extraSystemPrompt,
+ defaultScopes: service.defaultScopes
+ )
+ return (try? JSONEncoder().encode(state)) ?? Data()
+ }
+
+ 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() -> [ChatTabBuilder] {
+ let customCommands = UserDefaults.shared.value(for: \.customCommands).compactMap {
+ command in
+ if case .customChat = command.feature {
+ return Builder(title: command.name, customCommand: command)
+ }
+ return nil
+ }
+
+ 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) })
+ super.init(store: store)
+ }
+
+ public func start() {
+ observer = .init()
+ cancellable = []
+
+ chatTabStore.send(.updateTitle("Chat"))
+
+ service.$systemPrompt.removeDuplicates().sink { [weak self] _ in
+ Task { @MainActor [weak self] in
+ self?.chatTabStore.send(.tabContentUpdated)
+ }
+ }.store(in: &cancellable)
+
+ service.$extraSystemPrompt.removeDuplicates().sink { [weak self] _ in
+ Task { @MainActor [weak self] in
+ self?.chatTabStore.send(.tabContentUpdated)
+ }
+ }.store(in: &cancellable)
+
+ 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)
+ }
+ }
+ }
+
+ Task { @MainActor in
+ var lastTitle = ""
+ observer.observe { [weak self] in
+ guard let self else { return }
+ let title = self.chatTabStore.state.title
+ guard lastTitle != title else { return }
+ lastTitle = title
+ Task { @MainActor [weak self] in
+ self?.chatTabStore.send(.updateTitle(title))
+ }
+ }
+ }
+
+ 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
new file mode 100644
index 00000000..9210a05d
--- /dev/null
+++ b/Core/Sources/ChatGPTChatTab/ChatPanel.swift
@@ -0,0 +1,626 @@
+import AppKit
+import Combine
+import ComposableArchitecture
+import MarkdownUI
+import OpenAIService
+import SharedUIComponents
+import SwiftUI
+
+private let r: Double = 8
+
+public struct ChatPanel: View {
+ let chat: StoreOf
+ @Namespace var inputAreaNamespace
+
+ public var body: some View {
+ VStack(spacing: 0) {
+ ChatPanelMessages(chat: chat)
+ Divider()
+ ChatPanelInputArea(chat: chat)
+ }
+ .background(Color(nsColor: .windowBackgroundColor))
+ .onAppear { chat.send(.appear) }
+ }
+}
+
+private struct ScrollViewOffsetPreferenceKey: PreferenceKey {
+ static var defaultValue = CGFloat.zero
+
+ static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
+ value += nextValue()
+ }
+}
+
+private struct ListHeightPreferenceKey: PreferenceKey {
+ static var defaultValue = CGFloat.zero
+
+ static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
+ value += nextValue()
+ }
+}
+
+struct ChatPanelMessages: View {
+ let chat: StoreOf
+ @State var cancellable = Set()
+ @State var isScrollToBottomButtonDisplayed = true
+ @Namespace var bottomID
+ @Namespace var topID
+ @Namespace var scrollSpace
+ @State var scrollOffset: Double = 0
+ @State var listHeight: Double = 0
+ @State var didScrollToBottomOnAppearOnce = false
+ @State var isBottomHidden = true
+ @Environment(\.isEnabled) var isEnabled
+
+ var body: some View {
+ WithPerceptionTracking {
+ ScrollViewReader { proxy in
+ GeometryReader { listGeo in
+ List {
+ Group {
+ Spacer(minLength: 12)
+ .id(topID)
+
+ Instruction(chat: chat)
+
+ ChatHistory(chat: chat)
+ .listItemTint(.clear)
+
+ ExtraSpacingInResponding(chat: chat)
+
+ Spacer(minLength: 12)
+ .id(bottomID)
+ .onAppear {
+ isBottomHidden = false
+ if !didScrollToBottomOnAppearOnce {
+ proxy.scrollTo(bottomID, anchor: .bottom)
+ didScrollToBottomOnAppearOnce = true
+ }
+ }
+ .onDisappear {
+ isBottomHidden = true
+ }
+ .background(GeometryReader { geo in
+ let offset = geo.frame(in: .named(scrollSpace)).minY
+ Color.clear.preference(
+ key: ScrollViewOffsetPreferenceKey.self,
+ value: offset
+ )
+ })
+ }
+ .modify { view in
+ if #available(macOS 13.0, *) {
+ view
+ .listRowSeparator(.hidden)
+ .listSectionSeparator(.hidden)
+ } else {
+ view
+ }
+ }
+ }
+ .listStyle(.plain)
+ .listRowBackground(EmptyView())
+ .modify { view in
+ if #available(macOS 13.0, *) {
+ view.scrollContentBackground(.hidden)
+ } else {
+ view
+ }
+ }
+ .coordinateSpace(name: scrollSpace)
+ .preference(
+ key: ListHeightPreferenceKey.self,
+ value: listGeo.size.height
+ )
+ .onPreferenceChange(ListHeightPreferenceKey.self) { value in
+ listHeight = value
+ updatePinningState()
+ }
+ .onPreferenceChange(ScrollViewOffsetPreferenceKey.self) { value in
+ scrollOffset = value
+ updatePinningState()
+ }
+ .overlay(alignment: .bottom) {
+ StopRespondingButton(chat: chat)
+ }
+ .overlay(alignment: .bottomTrailing) {
+ scrollToBottomButton(proxy: proxy)
+ }
+ .background {
+ PinToBottomHandler(chat: chat, isBottomHidden: isBottomHidden) {
+ proxy.scrollTo(bottomID, anchor: .bottom)
+ }
+ }
+ .onAppear {
+ proxy.scrollTo(bottomID, anchor: .bottom)
+ }
+ .task {
+ proxy.scrollTo(bottomID, anchor: .bottom)
+ }
+ }
+ }
+ .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() {
+ // where does the 32 come from?
+ withAnimation(.linear(duration: 0.1)) {
+ isScrollToBottomButtonDisplayed = scrollOffset > listHeight + 32 + 20
+ || scrollOffset <= 0
+ }
+ }
+
+ @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)
+ }
+
+ struct ExtraSpacingInResponding: View {
+ let chat: StoreOf
+
+ var body: some View {
+ WithPerceptionTracking {
+ if chat.isReceivingMessage {
+ Spacer(minLength: 12)
+ }
+ }
+ }
+ }
+
+ struct PinToBottomHandler: View {
+ let chat: StoreOf
+ let isBottomHidden: Bool
+ let scrollToBottom: () -> Void
+
+ @State var isInitialLoad = true
+
+ 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 ChatHistory: View {
+ let chat: StoreOf
+
+ var body: some View {
+ WithPerceptionTracking {
+ ForEach(chat.history, id: \.id) { message in
+ WithPerceptionTracking {
+ ChatHistoryItem(chat: chat, message: message).id(message.id)
+ }
+ }
+ }
+ }
+}
+
+struct ChatHistoryItem: View {
+ let chat: StoreOf
+ let message: DisplayedChatMessage
+
+ var body: some View {
+ WithPerceptionTracking {
+ let text = message.text
+ let markdownContent = message.markdownContent
+ switch message.role {
+ case .user:
+ UserMessage(
+ id: message.id,
+ text: text,
+ markdownContent: markdownContent,
+ chat: chat
+ )
+ .listRowInsets(EdgeInsets(
+ top: 0,
+ leading: -8,
+ bottom: 0,
+ trailing: -8
+ ))
+ .padding(.vertical, 4)
+ case .assistant:
+ BotMessage(
+ id: message.id,
+ text: text,
+ markdownContent: markdownContent,
+ references: message.references,
+ chat: chat
+ )
+ .listRowInsets(EdgeInsets(
+ top: 0,
+ leading: -8,
+ bottom: 0,
+ trailing: -8
+ ))
+ .padding(.vertical, 4)
+ case .tool:
+ FunctionMessage(id: message.id, text: text)
+ case .ignored:
+ EmptyView()
+ }
+ }
+ }
+}
+
+private struct StopRespondingButton: View {
+ let chat: StoreOf
+
+ var body: some View {
+ WithPerceptionTracking {
+ if chat.isReceivingMessage {
+ Button(action: {
+ chat.send(.stopRespondingButtonTapped)
+ }) {
+ HStack(spacing: 4) {
+ Image(systemName: "stop.fill")
+ Text("Stop Responding")
+ }
+ .padding(8)
+ .background(
+ .regularMaterial,
+ in: RoundedRectangle(cornerRadius: r, style: .continuous)
+ )
+ .overlay {
+ RoundedRectangle(cornerRadius: r, style: .continuous)
+ .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
+ }
+ }
+ .buttonStyle(.borderless)
+ .frame(maxWidth: .infinity, alignment: .center)
+ .padding(.bottom, 8)
+ .opacity(chat.isReceivingMessage ? 1 : 0)
+ .disabled(!chat.isReceivingMessage)
+ .transformEffect(.init(
+ translationX: 0,
+ y: chat.isReceivingMessage ? 0 : 20
+ ))
+ }
+ }
+ }
+}
+
+struct ChatPanelInputArea: View {
+ let chat: StoreOf
+ @FocusState var focusedField: Chat.State.Field?
+
+ var body: some View {
+ HStack {
+ clearButton
+ InputAreaTextEditor(chat: chat, focusedField: $focusedField)
+ }
+ .padding(8)
+ .background(.ultraThickMaterial)
+ }
+
+ @MainActor
+ var clearButton: some View {
+ Button(action: {
+ chat.send(.clearButtonTap)
+ }) {
+ Group {
+ if #available(macOS 13.0, *) {
+ Image(systemName: "eraser.line.dashed.fill")
+ } else {
+ Image(systemName: "trash.fill")
+ }
+ }
+ .padding(6)
+ .background {
+ Circle().fill(Color(nsColor: .controlBackgroundColor))
+ }
+ .overlay {
+ Circle().stroke(Color(nsColor: .controlColor), lineWidth: 1)
+ }
+ }
+ .buttonStyle(.plain)
+ }
+
+ struct InputAreaTextEditor: View {
+ @Perception.Bindable var chat: StoreOf
+ var focusedField: FocusState.Binding
+
+ var body: some View {
+ WithPerceptionTracking {
+ HStack(spacing: 0) {
+ AutoresizingCustomTextEditor(
+ text: $chat.typedMessage,
+ font: .systemFont(ofSize: 14),
+ isEditable: true,
+ maxHeight: 400,
+ onSubmit: { chat.send(.sendButtonTapped) },
+ completions: chatAutoCompletion
+ )
+ .focused(focusedField, equals: .textField)
+ .bind($chat.focusedField, to: focusedField)
+ .padding(8)
+ .fixedSize(horizontal: false, vertical: true)
+
+ Button(action: {
+ chat.send(.sendButtonTapped)
+ }) {
+ Image(systemName: "paperplane.fill")
+ .padding(8)
+ }
+ .buttonStyle(.plain)
+ .disabled(chat.isReceivingMessage)
+ .keyboardShortcut(KeyEquivalent.return, modifiers: [])
+ }
+ .frame(maxWidth: .infinity)
+ .background {
+ RoundedRectangle(cornerRadius: 6)
+ .fill(Color(nsColor: .controlBackgroundColor))
+ }
+ .overlay {
+ RoundedRectangle(cornerRadius: 6)
+ .stroke(Color(nsColor: .controlColor), lineWidth: 1)
+ }
+ .background {
+ Button(action: {
+ chat.send(.returnButtonTapped)
+ }) {
+ EmptyView()
+ }
+ .keyboardShortcut(KeyEquivalent.return, modifiers: [.shift])
+
+ Button(action: {
+ focusedField.wrappedValue = .textField
+ }) {
+ EmptyView()
+ }
+ .keyboardShortcut("l", modifiers: [.command])
+ }
+ }
+ }
+
+ func chatAutoCompletion(text: String, proposed: [String], range: NSRange) -> [String] {
+ guard text.count == 1 else { return [] }
+ let plugins = [String]() // chat.pluginIdentifiers.map { "/\($0)" }
+ let availableFeatures = plugins + [
+ "/exit",
+ "@code",
+ "@sense",
+ "@project",
+ "@web",
+ ]
+
+ 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
+ }
+ }
+}
+
+// MARK: - Previews
+
+struct ChatPanel_Preview: PreviewProvider {
+ static let history: [DisplayedChatMessage] = [
+ .init(
+ id: "1",
+ role: .user,
+ text: "**Hello**",
+ references: []
+ ),
+ .init(
+ id: "2",
+ role: .assistant,
+ text: """
+ ```swift
+ 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: "3",
+ role: .user,
+ text: #"""
+ Please buy me a coffee!
+ | Coffee | Milk |
+ |--------|------|
+ | Espresso | No |
+ | Latte | Yes |
+
+ ```swift
+ func foo() {}
+ ```
+ ```objectivec
+ - (void)bar {}
+ ```
+ """#,
+ references: []
+ ),
+ ]
+
+ static var previews: some View {
+ ChatPanel(chat: .init(
+ initialState: .init(history: ChatPanel_Preview.history, isReceivingMessage: true),
+ reducer: { Chat(service: .init()) }
+ ))
+ .frame(width: 450, height: 1200)
+ .colorScheme(.dark)
+ }
+}
+
+struct ChatPanel_EmptyChat_Preview: PreviewProvider {
+ static var previews: some View {
+ ChatPanel(chat: .init(
+ initialState: .init(history: [DisplayedChatMessage](), isReceivingMessage: false),
+ reducer: { Chat(service: .init()) }
+ ))
+ .padding()
+ .frame(width: 450, height: 600)
+ .colorScheme(.dark)
+ }
+}
+
+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()) }
+ ))
+ .padding()
+ .frame(width: 450, height: 600)
+ .colorScheme(.dark)
+ }
+}
+
+struct ChatPanel_InputMultilineText_Preview: PreviewProvider {
+ static var previews: some View {
+ ChatPanel(
+ chat: .init(
+ initialState: .init(
+ typedMessage: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce turpis dolor, malesuada quis fringilla sit amet, placerat at nunc. Suspendisse orci tortor, tempor nec blandit a, malesuada vel tellus. Nunc sed leo ligula. Ut at ligula eget turpis pharetra tristique. Integer luctus leo non elit rhoncus fermentum.",
+
+ history: ChatPanel_Preview.history,
+ isReceivingMessage: false
+ ),
+ reducer: { Chat(service: .init()) }
+ )
+ )
+ .padding()
+ .frame(width: 450, height: 600)
+ .colorScheme(.dark)
+ }
+}
+
+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()) }
+ ))
+ .padding()
+ .frame(width: 450, height: 600)
+ .colorScheme(.light)
+ }
+}
+
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
new file mode 100644
index 00000000..6c117c9a
--- /dev/null
+++ b/Core/Sources/ChatGPTChatTab/Styles.swift
@@ -0,0 +1,167 @@
+import AppKit
+import MarkdownUI
+import SharedUIComponents
+import SwiftUI
+
+extension Color {
+ static var contentBackground: Color {
+ Color(nsColor: NSColor(name: nil, dynamicProvider: { appearance in
+ if appearance.isDarkMode {
+ return #colorLiteral(red: 0.1580096483, green: 0.1730263829, blue: 0.2026666105, alpha: 1)
+ }
+ return #colorLiteral(red: 0.9896564803, green: 0.9896564803, blue: 0.9896564803, alpha: 1)
+ }))
+ }
+
+ static var userChatContentBackground: Color {
+ Color(nsColor: NSColor(name: nil, dynamicProvider: { appearance in
+ if appearance.isDarkMode {
+ return #colorLiteral(red: 0.2284317913, green: 0.2145925438, blue: 0.3214019983, alpha: 1)
+ }
+ return #colorLiteral(red: 0.9458052187, green: 0.9311983998, blue: 0.9906365955, alpha: 1)
+ }))
+ }
+}
+
+extension NSAppearance {
+ var isDarkMode: Bool {
+ if bestMatch(from: [.darkAqua, .aqua]) == .darkAqua {
+ return true
+ } else {
+ return false
+ }
+ }
+}
+
+extension View {
+ var messageBubbleCornerRadius: Double { 8 }
+
+ func codeBlockLabelStyle() -> some View {
+ relativeLineSpacing(.em(0.225))
+ .markdownTextStyle {
+ FontFamilyVariant(.monospaced)
+ FontSize(.em(0.85))
+ }
+ .padding(16)
+ .padding(.top, 14)
+ }
+
+ func codeBlockStyle(
+ _ configuration: CodeBlockConfiguration,
+ backgroundColor: Color,
+ labelColor: Color
+ ) -> some View {
+ background(backgroundColor)
+ .clipShape(RoundedRectangle(cornerRadius: 6))
+ .overlay(alignment: .top) {
+ HStack(alignment: .center) {
+ Text(configuration.language ?? "code")
+ .foregroundStyle(labelColor)
+ .font(.callout.bold())
+ .padding(.leading, 8)
+ .lineLimit(1)
+ Spacer()
+ CopyButton {
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(configuration.content, forType: .string)
+ }
+ }
+ }
+ .overlay {
+ RoundedRectangle(cornerRadius: 6).stroke(Color.primary.opacity(0.05), lineWidth: 1)
+ }
+ .markdownMargin(top: 4, bottom: 16)
+ }
+}
+
+final class VerticalScrollingFixHostingView: NSHostingView where Content: View {
+ override func wantsForwardedScrollEvents(for axis: NSEvent.GestureAxis) -> Bool {
+ return axis == .vertical
+ }
+}
+
+struct VerticalScrollingFixViewRepresentable: NSViewRepresentable where Content: View {
+ let content: Content
+
+ func makeNSView(context: Context) -> NSHostingView {
+ return VerticalScrollingFixHostingView(rootView: content)
+ }
+
+ func updateNSView(_ nsView: NSHostingView, context: Context) {}
+}
+
+struct VerticalScrollingFixWrapper: View where Content: View {
+ let content: () -> Content
+
+ init(@ViewBuilder content: @escaping () -> Content) {
+ self.content = content
+ }
+
+ var body: some View {
+ VerticalScrollingFixViewRepresentable(content: self.content())
+ }
+}
+
+extension View {
+ /// https://stackoverflow.com/questions/64920744/swiftui-nested-scrollviews-problem-on-macos
+ @ViewBuilder func workaroundForVerticalScrollingBugInMacOS() -> some View {
+ VerticalScrollingFixWrapper { self }
+ }
+}
+
+struct RoundedCorners: Shape {
+ var tl: CGFloat = 0.0
+ var tr: CGFloat = 0.0
+ var bl: CGFloat = 0.0
+ var br: CGFloat = 0.0
+
+ func path(in rect: CGRect) -> Path {
+ Path { path in
+
+ let w = rect.size.width
+ let h = rect.size.height
+
+ // Make sure we do not exceed the size of the rectangle
+ let tr = min(min(self.tr, h / 2), w / 2)
+ let tl = min(min(self.tl, h / 2), w / 2)
+ let bl = min(min(self.bl, h / 2), w / 2)
+ let br = min(min(self.br, h / 2), w / 2)
+
+ path.move(to: CGPoint(x: w / 2.0, y: 0))
+ path.addLine(to: CGPoint(x: w - tr, y: 0))
+ path.addArc(
+ center: CGPoint(x: w - tr, y: tr),
+ radius: tr,
+ startAngle: Angle(degrees: -90),
+ endAngle: Angle(degrees: 0),
+ clockwise: false
+ )
+ path.addLine(to: CGPoint(x: w, y: h - br))
+ path.addArc(
+ center: CGPoint(x: w - br, y: h - br),
+ radius: br,
+ startAngle: Angle(degrees: 0),
+ endAngle: Angle(degrees: 90),
+ clockwise: false
+ )
+ path.addLine(to: CGPoint(x: bl, y: h))
+ path.addArc(
+ center: CGPoint(x: bl, y: h - bl),
+ radius: bl,
+ startAngle: Angle(degrees: 90),
+ endAngle: Angle(degrees: 180),
+ clockwise: false
+ )
+ path.addLine(to: CGPoint(x: 0, y: tl))
+ path.addArc(
+ center: CGPoint(x: tl, y: tl),
+ radius: tl,
+ startAngle: Angle(degrees: 180),
+ endAngle: Angle(degrees: 270),
+ clockwise: false
+ )
+ path.closeSubpath()
+ }
+ }
+}
+
diff --git a/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift b/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift
new file mode 100644
index 00000000..bcd9a455
--- /dev/null
+++ b/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift
@@ -0,0 +1,295 @@
+import ComposableArchitecture
+import Foundation
+import MarkdownUI
+import SharedUIComponents
+import SwiftUI
+
+struct BotMessage: View {
+ var r: Double { messageBubbleCornerRadius }
+ let id: String
+ let text: String
+ let markdownContent: MarkdownContent
+ let references: [DisplayedChatMessage.Reference]
+ let chat: StoreOf
+ @Environment(\.colorScheme) var colorScheme
+
+ @State var isReferencesPresented = false
+ @State var isReferencesHovered = false
+
+ var body: some View {
+ HStack(alignment: .bottom, spacing: 2) {
+ VStack(alignment: .leading, spacing: 16) {
+ if !references.isEmpty {
+ Button(action: {
+ isReferencesPresented.toggle()
+ }, label: {
+ HStack(spacing: 4) {
+ Image(systemName: "plus.circle")
+ Text("Used \(references.count) references")
+ }
+ .padding(8)
+ .background {
+ RoundedRectangle(cornerRadius: r - 4)
+ .foregroundStyle(Color(isReferencesHovered ? .black : .clear))
+ }
+ .overlay {
+ RoundedRectangle(cornerRadius: r - 4)
+ .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
+ }
+ .foregroundStyle(.secondary)
+ })
+ .buttonStyle(.plain)
+ .popover(isPresented: $isReferencesPresented, arrowEdge: .trailing) {
+ ReferenceList(references: references, chat: chat)
+ }
+ }
+
+ ThemedMarkdownText(markdownContent)
+ }
+ .frame(alignment: .trailing)
+ .padding()
+ .background {
+ RoundedCorners(tl: r, tr: r, bl: 0, br: r)
+ .fill(Color.contentBackground)
+ }
+ .overlay {
+ RoundedCorners(tl: r, tr: r, bl: 0, br: r)
+ .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
+ }
+ .padding(.leading, 8)
+ .shadow(color: .black.opacity(0.05), radius: 6)
+ .contextMenu {
+ Button("Copy") {
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(text, forType: .string)
+ }
+
+ Button("Set as Extra System Prompt") {
+ chat.send(.setAsExtraPromptButtonTapped(id))
+ }
+
+ Divider()
+
+ Button("Delete") {
+ chat.send(.deleteMessageButtonTapped(id))
+ }
+ }
+
+ CopyButton {
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(text, forType: .string)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.trailing, 2)
+ }
+}
+
+struct ReferenceList: View {
+ let references: [DisplayedChatMessage.Reference]
+ let chat: StoreOf
+
+ var body: some View {
+ WithPerceptionTracking {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 8) {
+ ForEach(0.. MarkdownUI.Theme {
+ .gitHub.text {
+ ForegroundColor(.secondary)
+ BackgroundColor(Color.clear)
+ FontSize(fontSize - 1)
+ }
+ .list { configuration in
+ configuration.label
+ .markdownMargin(top: 4, bottom: 4)
+ }
+ .paragraph { configuration in
+ configuration.label
+ .markdownMargin(top: 0, bottom: 4)
+ }
+ .codeBlock { configuration in
+ configuration.label
+ .relativeLineSpacing(.em(0.225))
+ .markdownTextStyle {
+ FontFamilyVariant(.monospaced)
+ FontSize(.em(0.85))
+ }
+ .padding(16)
+ .background(Color(nsColor: .textBackgroundColor).opacity(0.7))
+ .clipShape(RoundedRectangle(cornerRadius: 6))
+ .markdownMargin(top: 4, bottom: 4)
+ }
+ }
+}
diff --git a/Core/Sources/ChatGPTChatTab/Views/FunctionMessage.swift b/Core/Sources/ChatGPTChatTab/Views/FunctionMessage.swift
new file mode 100644
index 00000000..3e6b031a
--- /dev/null
+++ b/Core/Sources/ChatGPTChatTab/Views/FunctionMessage.swift
@@ -0,0 +1,30 @@
+import Foundation
+import MarkdownUI
+import SwiftUI
+
+struct FunctionMessage: View {
+ let id: String
+ let text: String
+ @AppStorage(\.chatFontSize) var chatFontSize
+
+ var body: some View {
+ Markdown(text)
+ .textSelection(.enabled)
+ .markdownTheme(.functionCall(fontSize: chatFontSize))
+ .padding(.vertical, 2)
+ .padding(.trailing, 2)
+ }
+}
+
+#Preview {
+ FunctionMessage(id: "1", text: """
+ Searching for something...
+ - abc
+ - [def](https://1.com)
+ > hello
+ > hi
+ """)
+ .padding()
+ .fixedSize()
+}
+
diff --git a/Core/Sources/ChatGPTChatTab/Views/InstructionMarkdownTheme.swift b/Core/Sources/ChatGPTChatTab/Views/InstructionMarkdownTheme.swift
new file mode 100644
index 00000000..30e786ea
--- /dev/null
+++ b/Core/Sources/ChatGPTChatTab/Views/InstructionMarkdownTheme.swift
@@ -0,0 +1,68 @@
+import Foundation
+import MarkdownUI
+import SwiftUI
+
+extension MarkdownUI.Theme {
+ static func instruction(fontSize: Double) -> MarkdownUI.Theme {
+ .gitHub.text {
+ ForegroundColor(.primary)
+ BackgroundColor(Color.clear)
+ FontSize(fontSize)
+ }
+ .code {
+ FontFamilyVariant(.monospaced)
+ FontSize(.em(0.85))
+ BackgroundColor(Color.secondary.opacity(0.2))
+ }
+ .codeBlock { configuration in
+ let wrapCode = UserDefaults.shared.value(for: \.wrapCodeInChatCodeBlock)
+
+ if wrapCode {
+ configuration.label
+ .codeBlockLabelStyle()
+ .codeBlockStyle(
+ configuration,
+ backgroundColor: Color(nsColor: .textBackgroundColor).opacity(0.7),
+ labelColor: Color.secondary.opacity(0.7)
+ )
+ } else {
+ ScrollView(.horizontal) {
+ configuration.label
+ .codeBlockLabelStyle()
+ }
+ .workaroundForVerticalScrollingBugInMacOS()
+ .codeBlockStyle(
+ configuration,
+ backgroundColor: Color(nsColor: .textBackgroundColor).opacity(0.7),
+ labelColor: Color.secondary.opacity(0.7)
+ )
+ }
+ }
+ .table { configuration in
+ configuration.label
+ .fixedSize(horizontal: false, vertical: true)
+ .markdownTableBorderStyle(.init(
+ color: .init(nsColor: .separatorColor),
+ strokeStyle: .init(lineWidth: 1)
+ ))
+ .markdownTableBackgroundStyle(
+ .alternatingRows(Color.secondary.opacity(0.1), Color.secondary.opacity(0.2))
+ )
+ .markdownMargin(top: 0, bottom: 16)
+ }
+ .tableCell { configuration in
+ configuration.label
+ .markdownTextStyle {
+ if configuration.row == 0 {
+ FontWeight(.semibold)
+ }
+ BackgroundColor(nil)
+ }
+ .fixedSize(horizontal: false, vertical: true)
+ .padding(.vertical, 6)
+ .padding(.horizontal, 13)
+ .relativeLineSpacing(.em(0.25))
+ }
+ }
+}
+
diff --git a/Core/Sources/ChatGPTChatTab/Views/Instructions.swift b/Core/Sources/ChatGPTChatTab/Views/Instructions.swift
new file mode 100644
index 00000000..dba6bfbf
--- /dev/null
+++ b/Core/Sources/ChatGPTChatTab/Views/Instructions.swift
@@ -0,0 +1,83 @@
+import ComposableArchitecture
+import Foundation
+import MarkdownUI
+import SwiftUI
+
+struct Instruction: View {
+ let chat: StoreOf
+
+ var body: some View {
+ WithPerceptionTracking {
+ Group {
+ Markdown(
+ """
+ You can use plugins to perform various tasks.
+
+ | Plugin Name | Description |
+ | --- | --- |
+ | `/shell` | Runs a command under the project root |
+ | `/shortcut(name)` | Runs a shortcut from the Shortcuts.app, with the previous message as input |
+
+ To use plugins, you can prefix a message with `/pluginName`.
+ """
+ )
+ .modifier(InstructionModifier())
+
+ Markdown(
+ """
+ You can use scopes to give the bot extra abilities.
+
+ | Scope Name | Abilities |
+ | --- | --- |
+ | `@file` | Read the metadata of the editing file |
+ | `@code` | Read the code and metadata in the editing file |
+ | `@sense`| Experimental. Read the relevant code of the focused editor |
+ | `@project` | Experimental. Access content of the project |
+ | `@web` (beta) | Search on Bing or query from a web page |
+
+ To use scopes, you can prefix a message with `@code`.
+
+ You can use shorthand to represent a scope, such as `@c`, and enable multiple scopes with `@c+web`.
+ """
+ )
+ .modifier(InstructionModifier())
+
+ let scopes = chat.chatMenu.defaultScopes
+ Markdown(
+ """
+ Hello, I am your AI programming assistant. I can identify issues, explain and even improve code.
+
+ \({
+ if scopes.isEmpty {
+ return "No scope is enabled by default"
+ } else {
+ let scopes = scopes.map(\.rawValue).sorted()
+ .joined(separator: ", ")
+ return "Default scopes: `\(scopes)`"
+ }
+ }())
+ """
+ )
+ .modifier(InstructionModifier())
+ }
+ }
+ }
+
+ struct InstructionModifier: ViewModifier {
+ @AppStorage(\.chatFontSize) var chatFontSize
+
+ func body(content: Content) -> some View {
+ content
+ .textSelection(.enabled)
+ .markdownTheme(.instruction(fontSize: chatFontSize))
+ .opacity(0.8)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding()
+ .overlay {
+ RoundedRectangle(cornerRadius: 8)
+ .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
+ }
+ }
+ }
+}
+
diff --git a/Core/Sources/ChatGPTChatTab/Views/ThemedMarkdownText.swift b/Core/Sources/ChatGPTChatTab/Views/ThemedMarkdownText.swift
new file mode 100644
index 00000000..2811e4ad
--- /dev/null
+++ b/Core/Sources/ChatGPTChatTab/Views/ThemedMarkdownText.swift
@@ -0,0 +1,111 @@
+import Foundation
+import MarkdownUI
+import SwiftUI
+
+struct ThemedMarkdownText: View {
+ @AppStorage(\.syncChatCodeHighlightTheme) var syncCodeHighlightTheme
+ @AppStorage(\.codeForegroundColorLight) var codeForegroundColorLight
+ @AppStorage(\.codeBackgroundColorLight) var codeBackgroundColorLight
+ @AppStorage(\.codeForegroundColorDark) var codeForegroundColorDark
+ @AppStorage(\.codeBackgroundColorDark) var codeBackgroundColorDark
+ @AppStorage(\.chatFontSize) var chatFontSize
+ @AppStorage(\.chatCodeFont) var chatCodeFont
+ @Environment(\.colorScheme) var colorScheme
+
+ let content: MarkdownContent
+
+ init(_ text: String) {
+ content = .init(text)
+ }
+
+ init(_ content: MarkdownContent) {
+ self.content = content
+ }
+
+ var body: some View {
+ Markdown(content)
+ .textSelection(.enabled)
+ .markdownTheme(.custom(
+ fontSize: chatFontSize,
+ codeFont: chatCodeFont.value.nsFont,
+ codeBlockBackgroundColor: {
+ if syncCodeHighlightTheme {
+ if colorScheme == .light, let color = codeBackgroundColorLight.value {
+ return color.swiftUIColor
+ } else if let color = codeBackgroundColorDark.value {
+ return color.swiftUIColor
+ }
+ }
+
+ return Color(nsColor: .textBackgroundColor).opacity(0.7)
+ }(),
+ codeBlockLabelColor: {
+ if syncCodeHighlightTheme {
+ if colorScheme == .light,
+ let color = codeForegroundColorLight.value
+ {
+ return color.swiftUIColor.opacity(0.5)
+ } else if let color = codeForegroundColorDark.value {
+ return color.swiftUIColor.opacity(0.5)
+ }
+ }
+ return Color.secondary.opacity(0.7)
+ }()
+ ))
+ }
+}
+
+// MARK: - Theme
+
+extension MarkdownUI.Theme {
+ static func custom(
+ fontSize: Double,
+ codeFont: NSFont,
+ codeBlockBackgroundColor: Color,
+ codeBlockLabelColor: Color
+ ) -> MarkdownUI.Theme {
+ .gitHub.text {
+ ForegroundColor(.primary)
+ BackgroundColor(Color.clear)
+ FontSize(fontSize)
+ }
+ .codeBlock { configuration in
+ let wrapCode = UserDefaults.shared.value(for: \.wrapCodeInChatCodeBlock)
+ || [
+ "plaintext", "text", "markdown", "sh", "console", "bash", "shell", "latex",
+ "tex"
+ ]
+ .contains(configuration.language)
+
+ if wrapCode {
+ AsyncCodeBlockView(
+ fenceInfo: configuration.language,
+ content: configuration.content,
+ font: codeFont
+ )
+ .codeBlockLabelStyle()
+ .codeBlockStyle(
+ configuration,
+ backgroundColor: codeBlockBackgroundColor,
+ labelColor: codeBlockLabelColor
+ )
+ } else {
+ ScrollView(.horizontal) {
+ AsyncCodeBlockView(
+ fenceInfo: configuration.language,
+ content: configuration.content,
+ font: codeFont
+ )
+ .codeBlockLabelStyle()
+ }
+ .workaroundForVerticalScrollingBugInMacOS()
+ .codeBlockStyle(
+ configuration,
+ backgroundColor: codeBlockBackgroundColor,
+ labelColor: codeBlockLabelColor
+ )
+ }
+ }
+ }
+}
+
diff --git a/Core/Sources/ChatGPTChatTab/Views/UserMessage.swift b/Core/Sources/ChatGPTChatTab/Views/UserMessage.swift
new file mode 100644
index 00000000..edac231a
--- /dev/null
+++ b/Core/Sources/ChatGPTChatTab/Views/UserMessage.swift
@@ -0,0 +1,80 @@
+import ComposableArchitecture
+import Foundation
+import MarkdownUI
+import SwiftUI
+
+struct UserMessage: View {
+ var r: Double { messageBubbleCornerRadius }
+ let id: String
+ let text: String
+ let markdownContent: MarkdownContent
+ let chat: StoreOf
+ @Environment(\.colorScheme) var colorScheme
+
+ var body: some View {
+ ThemedMarkdownText(markdownContent)
+ .frame(alignment: .leading)
+ .padding()
+ .background {
+ RoundedCorners(tl: r, tr: r, bl: r, br: 0)
+ .fill(Color.userChatContentBackground)
+ }
+ .overlay {
+ RoundedCorners(tl: r, tr: r, bl: r, br: 0)
+ .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
+ }
+ .padding(.leading)
+ .padding(.trailing, 8)
+ .shadow(color: .black.opacity(0.05), radius: 6)
+ .frame(maxWidth: .infinity, alignment: .trailing)
+ .contextMenu {
+ Button("Copy") {
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(text, forType: .string)
+ }
+
+ Button("Send Again") {
+ chat.send(.resendMessageButtonTapped(id))
+ }
+
+ Button("Set as Extra System Prompt") {
+ chat.send(.setAsExtraPromptButtonTapped(id))
+ }
+
+ Divider()
+
+ Button("Delete") {
+ chat.send(.deleteMessageButtonTapped(id))
+ }
+ }
+ }
+}
+
+#Preview {
+ let text = #"""
+ Please buy me a coffee!
+ | Coffee | Milk |
+ |--------|------|
+ | Espresso | No |
+ | Latte | Yes |
+ ```swift
+ func foo() {}
+ ```
+ ```objectivec
+ - (void)bar {}
+ ```
+ """#
+
+ return UserMessage(
+ id: "A",
+ text: text,
+ markdownContent: .init(text),
+ chat: .init(
+ initialState: .init(history: [] as [DisplayedChatMessage], isReceivingMessage: false),
+ reducer: { Chat(service: .init()) }
+ )
+ )
+ .padding()
+ .fixedSize(horizontal: true, vertical: true)
+}
+
diff --git a/Core/Sources/ChatService/AllContextCollector.swift b/Core/Sources/ChatService/AllContextCollector.swift
new file mode 100644
index 00000000..c13aa612
--- /dev/null
+++ b/Core/Sources/ChatService/AllContextCollector.swift
@@ -0,0 +1,19 @@
+import ActiveDocumentChatContextCollector
+import ChatContextCollector
+import SystemInfoChatContextCollector
+import WebChatContextCollector
+#if canImport(ProChatContextCollectors)
+import ProChatContextCollectors
+let allContextCollectors: [any ChatContextCollector] = [
+ SystemInfoChatContextCollector(),
+ WebChatContextCollector(),
+ ProChatContextCollectors(),
+]
+#else
+let allContextCollectors: [any ChatContextCollector] = [
+ SystemInfoChatContextCollector(),
+ ActiveDocumentChatContextCollector(),
+ WebChatContextCollector(),
+]
+#endif
+
diff --git a/Core/Sources/ChatService/AllPlugins.swift b/Core/Sources/ChatService/AllPlugins.swift
new file mode 100644
index 00000000..3f0b9de7
--- /dev/null
+++ b/Core/Sources/ChatService/AllPlugins.swift
@@ -0,0 +1,144 @@
+import ChatBasic
+import Foundation
+import OpenAIService
+import ShortcutChatPlugin
+import TerminalChatPlugin
+
+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
new file mode 100644
index 00000000..de8ca5bf
--- /dev/null
+++ b/Core/Sources/ChatService/ChatFunctionProvider.swift
@@ -0,0 +1,24 @@
+import ChatBasic
+import Foundation
+import OpenAIService
+
+final class ChatFunctionProvider {
+ var functions: [any ChatGPTFunction] = []
+
+ init() {}
+
+ func removeAll() {
+ functions = []
+ }
+
+ func append(functions others: [any ChatGPTFunction]) {
+ functions.append(contentsOf: others)
+ }
+}
+
+extension ChatFunctionProvider: ChatGPTFunctionProvider {
+ var functionCallStrategy: OpenAIService.FunctionCallStrategy? {
+ nil
+ }
+}
+
diff --git a/Core/Sources/ChatService/ChatPluginController.swift b/Core/Sources/ChatService/ChatPluginController.swift
new file mode 100644
index 00000000..c1a6d973
--- /dev/null
+++ b/Core/Sources/ChatService/ChatPluginController.swift
@@ -0,0 +1,141 @@
+import Combine
+import Foundation
+import LegacyChatPlugin
+import OpenAIService
+
+final class ChatPluginController {
+ let chatGPTService: any LegacyChatGPTServiceType
+ let plugins: [String: LegacyChatPlugin.Type]
+ var runningPlugin: LegacyChatPlugin?
+ weak var chatService: ChatService?
+
+ init(chatGPTService: any LegacyChatGPTServiceType, plugins: [LegacyChatPlugin.Type]) {
+ self.chatGPTService = chatGPTService
+ var all = [String: LegacyChatPlugin.Type]()
+ for plugin in plugins {
+ all[plugin.command.lowercased()] = plugin
+ }
+ self.plugins = all
+ }
+
+ convenience init(
+ chatGPTService: any LegacyChatGPTServiceType,
+ plugins: LegacyChatPlugin.Type...
+ ) {
+ self.init(chatGPTService: chatGPTService, plugins: plugins)
+ }
+
+ /// Handle the message in a plugin if required. Return false if no plugin handles the message.
+ func handleContent(_ content: String) async throws -> Bool {
+ // look for the prefix of content, see if there is something like /command.
+ // If there is, then we need to find the plugin that can handle this command.
+ // If there is no such plugin, then we just send the message to the GPT service.
+ let regex = try NSRegularExpression(pattern: #"^\/([a-zA-Z0-9]+)"#)
+ let matches = regex.matches(in: content, range: NSRange(content.startIndex..., in: content))
+ if let match = matches.first {
+ let command = String(content[Range(match.range(at: 1), in: content)!]).lowercased()
+ // handle exit plugin
+ if command == "exit" {
+ if let plugin = runningPlugin {
+ runningPlugin = nil
+ _ = await chatGPTService.memory.mutateHistory { history in
+ history.append(.init(
+ role: .user,
+ content: "",
+ summary: "Exit plugin \(plugin.name)."
+ ))
+ history.append(.init(
+ role: .system,
+ content: "",
+ summary: "Exited plugin \(plugin.name)."
+ ))
+ }
+ } else {
+ _ = await chatGPTService.memory.mutateHistory { history in
+ history.append(.init(
+ role: .system,
+ content: "",
+ summary: "No plugin running."
+ ))
+ }
+ }
+ return true
+ }
+
+ // pass message to running plugin
+ if let runningPlugin {
+ await runningPlugin.send(content: content, originalMessage: content)
+ return true
+ }
+
+ // pass message to new plugin
+ if let pluginType = plugins[command] {
+ let plugin = pluginType.init(inside: chatGPTService, delegate: self)
+ if #available(macOS 13.0, *) {
+ await plugin.send(
+ content: String(
+ content.dropFirst(command.count + 1)
+ .trimmingPrefix(while: { $0 == " " })
+ ),
+ originalMessage: content
+ )
+ } else {
+ await plugin.send(
+ content: String(content.dropFirst(command.count + 1)),
+ originalMessage: content
+ )
+ }
+ return true
+ }
+
+ return false
+ } else if let runningPlugin {
+ // pass message to running plugin
+ await runningPlugin.send(content: content, originalMessage: content)
+ return true
+ } else {
+ return false
+ }
+ }
+
+ func stopResponding() async {
+ await runningPlugin?.stopResponding()
+ }
+
+ func cancel() async {
+ await runningPlugin?.cancel()
+ }
+}
+
+// MARK: - ChatPluginDelegate
+
+extension ChatPluginController: LegacyChatPluginDelegate {
+ public func pluginDidStartResponding(_: LegacyChatPlugin) {
+ chatService?.isReceivingMessage = true
+ }
+
+ public func pluginDidEndResponding(_: LegacyChatPlugin) {
+ chatService?.isReceivingMessage = false
+ }
+
+ public func pluginDidStart(_ plugin: LegacyChatPlugin) {
+ runningPlugin = plugin
+ }
+
+ public func pluginDidEnd(_ plugin: LegacyChatPlugin) {
+ if runningPlugin === plugin {
+ runningPlugin = nil
+ }
+ }
+
+ 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
new file mode 100644
index 00000000..e1b0eb54
--- /dev/null
+++ b/Core/Sources/ChatService/ChatService.swift
@@ -0,0 +1,283 @@
+import ChatContextCollector
+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 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(
+ memory: ContextAwareAutoManagedChatGPTMemory,
+ configuration: OverridingChatGPTConfiguration,
+ chatGPTService: T
+ ) {
+ self.memory = memory
+ self.configuration = configuration
+ self.chatGPTService = chatGPTService
+ pluginController = ChatPluginController(
+ chatGPTService: chatGPTService,
+ plugins: allPlugins
+ )
+
+ pluginController.chatService = self
+ }
+
+ 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: extraConfiguration,
+ functionProvider: ChatFunctionProvider()
+ )
+ self.init(
+ memory: memory,
+ configuration: configuration,
+ chatGPTService: LegacyChatGPTService(
+ memory: memory,
+ configuration: extraConfiguration,
+ functionProvider: memory.functionProvider
+ )
+ )
+
+ resetDefaultScopes()
+
+ memory.chatService = self
+ memory.observeHistoryChange { [weak self] in
+ Task { [weak self] in
+ self?.chatHistory = await memory.history
+ }
+ }
+ }
+
+ public func resetDefaultScopes() {
+ 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 }
+ isReceivingMessage = true
+ defer { isReceivingMessage = false }
+
+ let stream = try await chatGPTService.send(content: content, summary: nil)
+ do {
+ for try await _ in stream {
+ try Task.checkCancellation()
+ }
+ } catch {}
+ }
+
+ public func sendAndWait(content: String) async throws -> String {
+ try await send(content: content)
+ if let reply = await memory.history.last(where: { $0.role == .assistant })?.content {
+ return reply
+ }
+ return ""
+ }
+
+ public func stopReceivingMessage() async {
+ await pluginController.stopResponding()
+ await chatGPTService.stopReceivingMessage()
+ isReceivingMessage = false
+
+ // if it's stopped before the tool calls finish, remove the message.
+ await memory.mutateHistory { history in
+ if history.last?.role == .assistant, history.last?.toolCalls != nil {
+ history.removeLast()
+ }
+ }
+ }
+
+ public func clearHistory() async {
+ await pluginController.cancel()
+ await memory.clearHistory()
+ await chatGPTService.stopReceivingMessage()
+ isReceivingMessage = false
+ }
+
+ public func resetPrompt() async {
+ systemPrompt = UserDefaults.shared.value(for: \.defaultChatSystemPrompt)
+ extraSystemPrompt = ""
+ }
+
+ public func deleteMessage(id: String) async {
+ await memory.removeMessage(id)
+ }
+
+ public func resendMessage(id: String) async throws {
+ if let message = (await memory.history).first(where: { $0.id == id }),
+ let content = message.content
+ {
+ try await send(content: content)
+ }
+ }
+
+ public func setMessageAsExtraPrompt(id: String) async {
+ if let message = (await memory.history).first(where: { $0.id == id }),
+ let content = message.content
+ {
+ mutateExtraSystemPrompt(content)
+ await mutateHistory { history in
+ history.append(.init(
+ role: .assistant,
+ content: "",
+ summary: "System prompt updated."
+ ))
+ }
+ }
+ }
+
+ /// Setting it to `nil` to reset the system prompt
+ public func mutateSystemPrompt(_ newPrompt: String?) {
+ systemPrompt = newPrompt ?? UserDefaults.shared.value(for: \.defaultChatSystemPrompt)
+ }
+
+ public func mutateExtraSystemPrompt(_ newPrompt: String) {
+ extraSystemPrompt = newPrompt
+ }
+
+ public func mutateHistory(_ mutator: @escaping (inout [ChatMessage]) -> Void) async {
+ await memory.mutateHistory(mutator)
+ }
+
+ public func handleCustomCommand(_ command: CustomCommand) async throws {
+ struct CustomCommandInfo {
+ var specifiedSystemPrompt: String?
+ var extraSystemPrompt: String?
+ var sendingMessageImmediately: String?
+ var name: String?
+ }
+
+ let info: CustomCommandInfo? = {
+ switch command.feature {
+ case let .chatWithSelection(extraSystemPrompt, prompt, useExtraSystemPrompt):
+ let updatePrompt = useExtraSystemPrompt ?? true
+ return .init(
+ extraSystemPrompt: updatePrompt ? extraSystemPrompt : nil,
+ sendingMessageImmediately: prompt,
+ name: command.name
+ )
+ case let .customChat(systemPrompt, prompt):
+ memory.contextController.defaultScopes = []
+ return .init(
+ specifiedSystemPrompt: systemPrompt,
+ extraSystemPrompt: "",
+ sendingMessageImmediately: prompt,
+ name: command.name
+ )
+ case .promptToCode: return nil
+ case .singleRoundDialog: return nil
+ }
+ }()
+
+ guard let info else { return }
+
+ let templateProcessor = CustomCommandTemplateProcessor()
+ 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)] " }
+ return ""
+ }()
+
+ if info.specifiedSystemPrompt != nil || info.extraSystemPrompt != nil {
+ await mutateHistory { history in
+ history.append(.init(
+ role: .assistant,
+ content: "",
+ summary: "\(customCommandPrefix)System prompt is updated."
+ ))
+ }
+ }
+
+ if let sendingMessageImmediately = info.sendingMessageImmediately,
+ !sendingMessageImmediately.isEmpty
+ {
+ try await send(content: templateProcessor.process(sendingMessageImmediately))
+ }
+ }
+
+ public func handleSingleRoundDialogCommand(
+ systemPrompt: String?,
+ overwriteSystemPrompt: Bool,
+ prompt: String
+ ) async throws -> String {
+ let templateProcessor = CustomCommandTemplateProcessor()
+ if let systemPrompt {
+ if overwriteSystemPrompt {
+ await mutateSystemPrompt(templateProcessor.process(systemPrompt))
+ } else {
+ await mutateExtraSystemPrompt(templateProcessor.process(systemPrompt))
+ }
+ }
+ return try await sendAndWait(content: templateProcessor.process(prompt))
+ }
+
+ public func processMessage(
+ systemPrompt: String?,
+ extraSystemPrompt: String?,
+ prompt: String
+ ) async throws -> String {
+ let templateProcessor = CustomCommandTemplateProcessor()
+ if let systemPrompt {
+ await mutateSystemPrompt(templateProcessor.process(systemPrompt))
+ }
+ if let 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
new file mode 100644
index 00000000..32d65694
--- /dev/null
+++ b/Core/Sources/ChatService/ContextAwareAutoManagedChatGPTMemory.swift
@@ -0,0 +1,54 @@
+import Foundation
+import OpenAIService
+
+public final class ContextAwareAutoManagedChatGPTMemory: ChatGPTMemory {
+ private let memory: AutoManagedChatGPTMemory
+ let contextController: DynamicContextController
+ let functionProvider: ChatFunctionProvider
+ weak var chatService: ChatService?
+
+ public var history: [ChatMessage] {
+ get async { await memory.history }
+ }
+
+ func observeHistoryChange(_ observer: @escaping () -> Void) {
+ memory.observeHistoryChange(observer)
+ }
+
+ init(
+ configuration: OverridingChatGPTConfiguration,
+ functionProvider: ChatFunctionProvider
+ ) {
+ memory = AutoManagedChatGPTMemory(
+ systemPrompt: "",
+ configuration: configuration,
+ functionProvider: functionProvider,
+ maxNumberOfMessages: UserDefaults.shared.value(for: \.chatGPTMaxMessageCount)
+ )
+ contextController = DynamicContextController(
+ memory: memory,
+ functionProvider: functionProvider,
+ configuration: configuration,
+ contextCollectors: allContextCollectors
+ )
+ self.functionProvider = functionProvider
+ }
+
+ public func mutateHistory(_ update: (inout [ChatMessage]) -> Void) async {
+ await memory.mutateHistory(update)
+ }
+
+ public func generatePrompt() async -> ChatGPTPrompt {
+ let content = (await memory.history)
+ .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
new file mode 100644
index 00000000..11ae9753
--- /dev/null
+++ b/Core/Sources/ChatService/DynamicContextController.swift
@@ -0,0 +1,123 @@
+import ChatContextCollector
+import Foundation
+import OpenAIService
+import Preferences
+import XcodeInspector
+
+final class DynamicContextController {
+ let contextCollectors: [ChatContextCollector]
+ let memory: AutoManagedChatGPTMemory
+ let functionProvider: ChatFunctionProvider
+ let configuration: OverridingChatGPTConfiguration
+ var defaultScopes = [] as Set
+
+ convenience init(
+ memory: AutoManagedChatGPTMemory,
+ functionProvider: ChatFunctionProvider,
+ configuration: OverridingChatGPTConfiguration,
+ contextCollectors: ChatContextCollector...
+ ) {
+ self.init(
+ memory: memory,
+ functionProvider: functionProvider,
+ configuration: configuration,
+ contextCollectors: contextCollectors
+ )
+ }
+
+ init(
+ memory: AutoManagedChatGPTMemory,
+ functionProvider: ChatFunctionProvider,
+ configuration: OverridingChatGPTConfiguration,
+ contextCollectors: [ChatContextCollector]
+ ) {
+ self.memory = memory
+ self.functionProvider = functionProvider
+ self.configuration = configuration
+ self.contextCollectors = contextCollectors
+ }
+
+ func collectContextInformation(systemPrompt: String, content: String) async throws {
+ var content = content
+ var scopes = Self.parseScopes(&content)
+ scopes.formUnion(defaultScopes)
+
+ let overridingChatModelId = {
+ var ids = [String]()
+ if scopes.contains(.sense) {
+ ids.append(UserDefaults.shared.value(for: \.preferredChatModelIdForSenseScope))
+ }
+
+ if scopes.contains(.project) {
+ ids.append(UserDefaults.shared.value(for: \.preferredChatModelIdForProjectScope))
+ }
+
+ if scopes.contains(.web) {
+ ids.append(UserDefaults.shared.value(for: \.preferredChatModelIdForWebScope))
+ }
+
+ let chatModels = UserDefaults.shared.value(for: \.chatModels)
+ let idIndexMap = chatModels.enumerated().reduce(into: [String: Int]()) {
+ $0[$1.element.id] = $1.offset
+ }
+ return ids.filter { !$0.isEmpty }.sorted(by: {
+ let lhs = idIndexMap[$0] ?? Int.max
+ let rhs = idIndexMap[$1] ?? Int.max
+ return lhs < rhs
+ }).first
+ }()
+
+ configuration.overriding.modelId = overridingChatModelId
+
+ functionProvider.removeAll()
+ let language = UserDefaults.shared.value(for: \.chatGPTLanguage)
+ let oldMessages = await memory.history
+ let contexts = await withTaskGroup(
+ of: ChatContext.self
+ ) { [scopes, content, configuration] group in
+ for collector in contextCollectors {
+ group.addTask {
+ await collector.generateContext(
+ history: oldMessages,
+ scopes: scopes,
+ content: content,
+ configuration: configuration
+ )
+ }
+ }
+ var contexts = [ChatContext]()
+ for await context in group {
+ contexts.append(context)
+ }
+ return contexts
+ }
+
+ let contextSystemPrompt = contexts
+ .map(\.systemPrompt)
+ .filter { !$0.isEmpty }
+ .joined(separator: "\n\n")
+
+ let retrievedContent = contexts
+ .flatMap(\.retrievedContent)
+ .filter { !$0.document.content.isEmpty }
+ .sorted { $0.priority > $1.priority }
+ .prefix(15)
+
+ let contextualSystemPrompt = """
+ \(language.isEmpty ? "" : "You must always reply in \(language)")
+ \(systemPrompt)
+ """.trimmingCharacters(in: .whitespacesAndNewlines)
+ await memory.mutateSystemPrompt(contextualSystemPrompt)
+ await memory.mutateContextSystemPrompt(contextSystemPrompt)
+ await memory.mutateRetrievedContent(retrievedContent.map(\.document))
+ functionProvider.append(functions: contexts.flatMap(\.functions))
+ }
+}
+
+extension DynamicContextController {
+ static func parseScopes(_ prompt: inout String) -> Set {
+ 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 099d85e3..00000000
--- a/Core/Sources/Client/AsyncXPCService.swift
+++ /dev/null
@@ -1,231 +0,0 @@
-import CopilotModel
-import Foundation
-import XPCShared
-
-public struct AsyncXPCService {
- public var connection: NSXPCConnection { service.connection }
- let service: XPCService
-
- init(service: XPCService) {
- self.service = service
- }
-
- public func checkStatus() async throws -> CopilotStatus {
- try await withXPCServiceConnected(connection: connection) {
- service, continuation in
- service.checkStatus { status, error in
- if let error {
- continuation.reject(error)
- return
- }
- continuation.resume(
- status.flatMap(CopilotStatus.init(rawValue:))
- ?? CopilotStatus.notAuthorized
- )
- }
- }
- }
-
- 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 getVersion() async throws -> String {
- try await withXPCServiceConnected(connection: connection) {
- service, continuation in
- service.getVersion { version, error in
- if let error {
- continuation.reject(error)
- return
- }
- continuation.resume(version ?? "--")
- }
- }
- }
-
- public func signInInitiate() async throws -> (verificationUri: String, userCode: String) {
- try await withXPCServiceConnected(connection: connection) {
- service, continuation in
- service.signInInitiate { verificationUri, userCode, error in
- if let error {
- continuation.reject(error)
- return
- }
- continuation.resume((verificationUri ?? "", userCode ?? ""))
- }
- }
- }
-
- public func signInConfirm(userCode: String) async throws
- -> (username: String, status: CopilotStatus)
- {
- try await withXPCServiceConnected(connection: connection) {
- service, continuation in
- service.signInConfirm(userCode: userCode) { username, status, error in
- if let error {
- continuation.reject(error)
- return
- }
- continuation.resume((
- username ?? "",
- status.flatMap(CopilotStatus.init(rawValue:)) ?? .alreadySignedIn
- ))
- }
- }
- }
-
- public func signOut() async throws -> CopilotStatus {
- try await withXPCServiceConnected(connection: connection) {
- service, continuation in
- service.signOut { finishstatus, error in
- if let error {
- continuation.reject(error)
- return
- }
- continuation
- .resume(finishstatus.flatMap(CopilotStatus.init(rawValue:)) ?? .notSignedIn)
- }
- }
- }
-
- 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 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(())
- }
- }
- }
-}
-
-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 17067f97..24a50bab 100644
--- a/Core/Sources/Client/XPCService.swift
+++ b/Core/Sources/Client/XPCService.swift
@@ -1,59 +1,14 @@
import Foundation
+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
- os_log(.info, "XPCService Invalidated")
- self?.isInvalidated = true
- }
- connection.interruptionHandler = { [weak self] in
- os_log(.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/CopilotModel/CopilotCompletion.swift b/Core/Sources/CopilotModel/CopilotCompletion.swift
deleted file mode 100644
index 2ace8c7a..00000000
--- a/Core/Sources/CopilotModel/CopilotCompletion.swift
+++ /dev/null
@@ -1,23 +0,0 @@
-import Foundation
-
-public struct CopilotCompletion: Codable {
- public init(
- text: String,
- position: CursorPosition,
- uuid: String,
- range: CursorRange,
- displayText: String
- ) {
- self.text = text
- self.position = position
- self.uuid = uuid
- self.range = range
- self.displayText = displayText
- }
-
- public var text: String
- public var position: CursorPosition
- public var uuid: String
- public var range: CursorRange
- public var displayText: String
-}
diff --git a/Core/Sources/CopilotModel/ExportedFromLSP.swift b/Core/Sources/CopilotModel/ExportedFromLSP.swift
deleted file mode 100644
index 077447c8..00000000
--- a/Core/Sources/CopilotModel/ExportedFromLSP.swift
+++ /dev/null
@@ -1,8 +0,0 @@
-import LanguageServerProtocol
-
-public typealias CursorPosition = LanguageServerProtocol.Position
-public typealias CursorRange = LanguageServerProtocol.LSPRange
-
-public extension CursorPosition {
- static var outOfScope: CursorPosition { .init(line: -1, character: -1) }
-}
diff --git a/Core/Sources/CopilotService/CopilotLocalProcessServer.swift b/Core/Sources/CopilotService/CopilotLocalProcessServer.swift
deleted file mode 100644
index e991f037..00000000
--- a/Core/Sources/CopilotService/CopilotLocalProcessServer.swift
+++ /dev/null
@@ -1,206 +0,0 @@
-import Foundation
-import JSONRPC
-import LanguageClient
-import LanguageServerProtocol
-
-import ProcessEnv
-
-/// A clone of the `LocalProcessServer`.
-/// We need it because the original one does not allow us to handle custom notifications.
-public class CopilotLocalProcessServer {
- private let transport: StdioDataTransport
- private let process: Process
- private var wrappedServer: CustomJSONRPCLanguageServer?
- public var terminationHandler: (() -> Void)?
-
- public convenience init(
- path: String,
- arguments: [String],
- environment: [String: String]? = nil
- ) {
- let params = Process.ExecutionParameters(
- path: path,
- arguments: arguments,
- environment: environment
- )
-
- self.init(executionParameters: params)
- }
-
- public init(executionParameters parameters: Process.ExecutionParameters) {
- transport = StdioDataTransport()
- wrappedServer = CustomJSONRPCLanguageServer(dataTransport: transport)
-
- process = Process()
-
- process.standardInput = transport.stdinPipe
- process.standardOutput = transport.stdoutPipe
- process.standardError = transport.stderrPipe
-
- process.parameters = parameters
-
- process.terminationHandler = { [unowned self] task in
- self.processTerminated(task)
- }
-
- process.launch()
- }
-
- deinit {
- process.terminationHandler = nil
- process.terminate()
- transport.close()
- }
-
- private func processTerminated(_: Process) {
- transport.close()
-
- // releasing the server here will short-circuit any pending requests,
- // which might otherwise take a while to time out, if ever.
- wrappedServer = nil
- terminationHandler?()
- }
-
- public var logMessages: Bool {
- get { return wrappedServer?.logMessages ?? false }
- set { wrappedServer?.logMessages = newValue }
- }
-}
-
-extension CopilotLocalProcessServer: LanguageServerProtocol.Server {
- public var requestHandler: RequestHandler? {
- get { return wrappedServer?.requestHandler }
- set { wrappedServer?.requestHandler = newValue }
- }
-
- public var notificationHandler: NotificationHandler? {
- get { wrappedServer?.notificationHandler }
- set { wrappedServer?.notificationHandler = newValue }
- }
-
- public func sendNotification(
- _ notif: ClientNotification,
- completionHandler: @escaping (ServerError?) -> Void
- ) {
- guard let server = wrappedServer, process.isRunning else {
- completionHandler(.serverUnavailable)
- return
- }
-
- server.sendNotification(notif, completionHandler: completionHandler)
- }
-
- public func sendRequest(
- _ request: ClientRequest,
- completionHandler: @escaping (ServerResult) -> Void
- ) {
- guard let server = wrappedServer, process.isRunning else {
- completionHandler(.failure(.serverUnavailable))
- return
- }
-
- server.sendRequest(request, completionHandler: completionHandler)
- }
-}
-
-final class CustomJSONRPCLanguageServer: Server {
- let internalServer: JSONRPCLanguageServer
-
- typealias ProtocolResponse = ProtocolTransport.ResponseResult
-
- private let protocolTransport: ProtocolTransport
-
- public var requestHandler: RequestHandler?
- public var notificationHandler: NotificationHandler?
-
- private var outOfBandError: Error?
-
- init(protocolTransport: ProtocolTransport) {
- self.protocolTransport = protocolTransport
- internalServer = JSONRPCLanguageServer(protocolTransport: protocolTransport)
-
- let previouseRequestHandler = protocolTransport.requestHandler
- let previouseNotificationHandler = protocolTransport.notificationHandler
-
- protocolTransport
- .requestHandler = { [weak self] in
- guard let self else { return }
- if !self.handleRequest($0, data: $1, callback: $2) {
- previouseRequestHandler?($0, $1, $2)
- }
- }
- protocolTransport
- .notificationHandler = { [weak self] in
- guard let self else { return }
- if !self.handleNotification($0, data: $1, block: $2) {
- previouseNotificationHandler?($0, $1, $2)
- }
- }
- }
-
- convenience init(dataTransport: DataTransport) {
- let framing = SeperatedHTTPHeaderMessageFraming()
- let messageTransport = MessageTransport(
- dataTransport: dataTransport,
- messageProtocol: framing
- )
-
- self.init(protocolTransport: ProtocolTransport(dataTransport: messageTransport))
- }
-
- deinit {
- protocolTransport.requestHandler = nil
- protocolTransport.notificationHandler = nil
- }
-
- var logMessages: Bool {
- get { return internalServer.logMessages }
- set { internalServer.logMessages = newValue }
- }
-}
-
-extension CustomJSONRPCLanguageServer {
- private func handleNotification(
- _ anyNotification: AnyJSONRPCNotification,
- data: Data,
- block: @escaping (Error?) -> Void
- ) -> Bool {
- let methodName = anyNotification.method
- switch methodName {
- case "LogMessage":
- block(nil)
- return true
- case "statusNotification":
- block(nil)
- return true
- default:
- return false
- }
- }
-
- public func sendNotification(
- _ notif: ClientNotification,
- completionHandler: @escaping (ServerError?) -> Void
- ) {
- internalServer.sendNotification(notif, completionHandler: completionHandler)
- }
-}
-
-extension CustomJSONRPCLanguageServer {
- private func handleRequest(
- _ request: AnyJSONRPCRequest,
- data: Data,
- callback: @escaping (AnyJSONRPCResponse) -> Void
- ) -> Bool {
- return false
- }
-}
-
-extension CustomJSONRPCLanguageServer {
- public func sendRequest(
- _ request: ClientRequest,
- completionHandler: @escaping (ServerResult) -> Void
- ) {
- internalServer.sendRequest(request, completionHandler: completionHandler)
- }
-}
diff --git a/Core/Sources/CopilotService/CopilotRequest.swift b/Core/Sources/CopilotService/CopilotRequest.swift
deleted file mode 100644
index 74650867..00000000
--- a/Core/Sources/CopilotService/CopilotRequest.swift
+++ /dev/null
@@ -1,173 +0,0 @@
-import CopilotModel
-import Foundation
-import JSONRPC
-import LanguageServerProtocol
-
-struct CopilotDoc: Codable {
- var source: String
- var tabSize: Int
- var indentSize: Int
- var insertSpaces: Bool
- var path: String
- var uri: String
- var relativePath: String
- var languageId: LanguageIdentifier
- var position: Position
- /// Buffer version. Not sure what this is for, not sure how to get it
- var version: Int = 0
-}
-
-protocol CopilotRequestType {
- associatedtype Response: Codable
- var request: ClientRequest { get }
-}
-
-enum CopilotRequest {
- struct SetEditorInfo: CopilotRequestType {
- struct Response: Codable {}
-
- var request: ClientRequest {
- .custom("setEditorInfo", .hash([
- "editorInfo": .hash([
- "name": "Xcode",
- "version": "",
- ]),
- "editorPluginInfo": .hash([
- "name": "Copilot for Xcode",
- "version": "",
- ]),
- ]))
- }
- }
-
- struct GetVersion: CopilotRequestType {
- struct Response: Codable {
- var version: String
- }
-
- var request: ClientRequest {
- .custom("getVersion", .hash([:]))
- }
- }
-
- struct CheckStatus: CopilotRequestType {
- struct Response: Codable {
- var status: CopilotStatus
- }
-
- var request: ClientRequest {
- .custom("checkStatus", .hash([:]))
- }
- }
-
- struct SignInInitiate: CopilotRequestType {
- struct Response: Codable {
- var verificationUri: String
- var status: String
- var userCode: String
- var expiresIn: Int
- var interval: Int
- }
-
- var request: ClientRequest {
- .custom("signInInitiate", .hash([:]))
- }
- }
-
- struct SignInConfirm: CopilotRequestType {
- struct Response: Codable {
- var status: CopilotStatus
- var user: String
- }
-
- var userCode: String
-
- var request: ClientRequest {
- .custom("signInConfirm", .hash([
- "userCode": .string(userCode),
- ]))
- }
- }
-
- struct SignOut: CopilotRequestType {
- struct Response: Codable {
- var status: CopilotStatus
- }
-
- var request: ClientRequest {
- .custom("signOut", .hash([:]))
- }
- }
-
- struct GetCompletions: CopilotRequestType {
- struct Response: Codable {
- var completions: [CopilotCompletion]
- }
-
- var doc: CopilotDoc
-
- var request: ClientRequest {
- let data = (try? JSONEncoder().encode(doc)) ?? Data()
- let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:])
- return .custom("getCompletions", .hash([
- "doc": dict,
- ]))
- }
- }
-
- struct GetCompletionsCycling: CopilotRequestType {
- struct Response: Codable {
- var completions: [CopilotCompletion]
- }
-
- var doc: CopilotDoc
-
- var request: ClientRequest {
- let data = (try? JSONEncoder().encode(doc)) ?? Data()
- let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:])
- return .custom("getCompletionsCycling", .hash([
- "doc": dict,
- ]))
- }
- }
-
- struct GetPanelCompletions: CopilotRequestType {
- struct Response: Codable {
- var completions: [CopilotCompletion]
- }
-
- var doc: CopilotDoc
-
- var request: ClientRequest {
- let data = (try? JSONEncoder().encode(doc)) ?? Data()
- let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:])
- return .custom("getPanelCompletions", .hash([
- "doc": dict,
- ]))
- }
- }
-
- struct NotifyAccepted: CopilotRequestType {
- struct Response: Codable {}
-
- var completionUUID: String
-
- var request: ClientRequest {
- .custom("notifyAccepted", .hash([
- "uuid": .string(completionUUID),
- ]))
- }
- }
-
- struct NotifyRejected: CopilotRequestType {
- struct Response: Codable {}
-
- var completionUUIDs: [String]
-
- var request: ClientRequest {
- .custom("notifyRejected", .hash([
- "uuids": .array(completionUUIDs.map(JSONValue.string)),
- ]))
- }
- }
-}
diff --git a/Core/Sources/CopilotService/CopilotService.swift b/Core/Sources/CopilotService/CopilotService.swift
deleted file mode 100644
index 478f2570..00000000
--- a/Core/Sources/CopilotService/CopilotService.swift
+++ /dev/null
@@ -1,215 +0,0 @@
-import CopilotModel
-import Foundation
-import LanguageClient
-import LanguageServerProtocol
-import XPCShared
-
-public protocol CopilotAuthServiceType {
- func checkStatus() async throws -> CopilotStatus
- func signInInitiate() async throws -> (verificationUri: String, userCode: String)
- func signInConfirm(userCode: String) async throws -> (username: String, status: CopilotStatus)
- func signOut() async throws -> CopilotStatus
- func version() async throws -> String
-}
-
-public protocol CopilotSuggestionServiceType {
- func getCompletions(
- fileURL: URL,
- content: String,
- cursorPosition: CursorPosition,
- tabSize: Int,
- indentSize: Int,
- usesTabsForIndentation: Bool,
- ignoreSpaceOnlySuggestions: Bool
- ) async throws -> [CopilotCompletion]
- func notifyAccepted(_ completion: CopilotCompletion) async
- func notifyRejected(_ completions: [CopilotCompletion]) async
-}
-
-protocol CopilotLSP {
- func sendRequest(_ endpoint: E) async throws -> E.Response
-}
-
-public class CopilotBaseService {
- let projectRootURL: URL
- var server: CopilotLSP
-
- init(designatedServer: CopilotLSP) {
- projectRootURL = URL(fileURLWithPath: "/")
- server = designatedServer
- }
-
- init(projectRootURL: URL) {
- self.projectRootURL = projectRootURL
- server = {
- let supportURL = FileManager.default.urls(
- for: .applicationSupportDirectory,
- in: .userDomainMask
- ).first!.appendingPathComponent("com.intii.CopilotForXcode")
- if !FileManager.default.fileExists(atPath: supportURL.path) {
- try? FileManager.default
- .createDirectory(at: supportURL, withIntermediateDirectories: false)
- }
- let executionParams = {
- let nodePath = UserDefaults.shared.string(forKey: SettingsKey.nodePath) ?? ""
- return Process.ExecutionParameters(
- path: "/usr/bin/env",
- arguments: [
- nodePath.isEmpty ? "node" : nodePath,
- Bundle.main.url(
- forResource: "agent",
- withExtension: "js",
- subdirectory: "copilot/dist"
- )!.path,
- "--stdio",
- ],
- environment: [
- "PATH": "/usr/bin:/usr/local/bin",
- ],
- currentDirectoryURL: supportURL
- )
- }()
- let localServer = CopilotLocalProcessServer(executionParameters: executionParams)
- localServer.logMessages = false
- localServer.notificationHandler = { _, respond in
- respond(.timeout)
- }
- let server = InitializingServer(server: localServer)
-
- server.initializeParamsProvider = {
- let capabilities = ClientCapabilities(
- workspace: nil,
- textDocument: nil,
- window: nil,
- general: nil,
- experimental: nil
- )
-
- return InitializeParams(
- processId: Int(ProcessInfo.processInfo.processIdentifier),
- clientInfo: .init(name: "Copilot for Xcode"),
- locale: nil,
- rootPath: projectRootURL.path,
- rootUri: projectRootURL.path,
- initializationOptions: nil,
- capabilities: capabilities,
- trace: .off,
- workspaceFolders: nil
- )
- }
-
- return server
- }()
- }
-}
-
-public final class CopilotAuthService: CopilotBaseService, CopilotAuthServiceType {
- public init() {
- let home = FileManager.default.homeDirectoryForCurrentUser
- super.init(projectRootURL: home)
- Task {
- try? await server.sendRequest(CopilotRequest.SetEditorInfo())
- }
- }
-
- public func checkStatus() async throws -> CopilotStatus {
- try await server.sendRequest(CopilotRequest.CheckStatus()).status
- }
-
- public func signInInitiate() async throws -> (verificationUri: String, userCode: String) {
- let result = try await server.sendRequest(CopilotRequest.SignInInitiate())
- return (result.verificationUri, result.userCode)
- }
-
- public func signInConfirm(userCode: String) async throws
- -> (username: String, status: CopilotStatus)
- {
- let result = try await server.sendRequest(CopilotRequest.SignInConfirm(userCode: userCode))
- return (result.user, result.status)
- }
-
- public func signOut() async throws -> CopilotStatus {
- try await server.sendRequest(CopilotRequest.SignOut()).status
- }
-
- public func version() async throws -> String {
- try await server.sendRequest(CopilotRequest.GetVersion()).version
- }
-}
-
-public final class CopilotSuggestionService: CopilotBaseService, CopilotSuggestionServiceType {
- override public init(projectRootURL: URL = URL(fileURLWithPath: "/")) {
- super.init(projectRootURL: projectRootURL)
- }
-
- override init(designatedServer: CopilotLSP) {
- super.init(designatedServer: designatedServer)
- }
-
- public func getCompletions(
- fileURL: URL,
- content: String,
- cursorPosition: CursorPosition,
- tabSize: Int,
- indentSize: Int,
- usesTabsForIndentation: Bool,
- ignoreSpaceOnlySuggestions: Bool
- ) async throws -> [CopilotCompletion] {
- guard let languageId = languageIdentifierFromFileURL(fileURL) else { return [] }
-
- let relativePath = {
- let filePath = fileURL.path
- let rootPath = projectRootURL.path
- if let range = filePath.range(of: rootPath),
- range.lowerBound == filePath.startIndex
- {
- let relativePath = filePath.replacingCharacters(
- in: filePath.startIndex..(_ endpoint: E) async throws -> E.Response {
- try await sendRequest(endpoint.request)
- }
-}
diff --git a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManagementView.swift b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManagementView.swift
new file mode 100644
index 00000000..bc6c910e
--- /dev/null
+++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManagementView.swift
@@ -0,0 +1,148 @@
+import ComposableArchitecture
+import SharedUIComponents
+import SwiftUI
+
+struct APIKeyManagementView: View {
+ @Perception.Bindable var store: StoreOf
+
+ var body: some View {
+ 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)
+ }
+ .background(Color(nsColor: .separatorColor))
+
+ List {
+ ForEach(store.availableAPIKeyNames, id: \.self) { name in
+ WithPerceptionTracking {
+ HStack {
+ Text(name)
+ .contextMenu {
+ Button("Remove") {
+ store.send(.deleteButtonClicked(name: name))
+ }
+ }
+ Spacer()
+
+ Button(action: {
+ store.send(.deleteButtonClicked(name: name))
+ }) {
+ Image(systemName: "trash.fill")
+ .foregroundStyle(.secondary)
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+ .modify { view in
+ if #available(macOS 13.0, *) {
+ view.listRowSeparator(.hidden).listSectionSeparator(.hidden)
+ } else {
+ view
+ }
+ }
+ }
+ .removeBackground()
+ .overlay {
+ if store.availableAPIKeyNames.isEmpty {
+ Text("""
+ Empty
+ Add a new key by clicking the add button
+ """)
+ .multilineTextAlignment(.center)
+ .padding()
+ }
+ }
+ }
+ .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 {
+ @Perception.Bindable var store: StoreOf
+
+ var body: some View {
+ WithPerceptionTracking {
+ ScrollView {
+ VStack(spacing: 0) {
+ Form {
+ TextField("Name", text: $store.name)
+ SecureField("Key", text: $store.key)
+ }
+ .padding()
+
+ Divider()
+
+ HStack {
+ Spacer()
+
+ Button("Cancel") { store.send(.cancelButtonClicked) }
+ .keyboardShortcut(.cancelAction)
+
+ Button("Save", action: { store.send(.saveButtonClicked) })
+ .keyboardShortcut(.defaultAction)
+ }.padding()
+ }
+ }
+ .textFieldStyle(.roundedBorder)
+ }
+ }
+}
+
+class APIKeyManagementView_Preview: PreviewProvider {
+ static var previews: some View {
+ APIKeyManagementView(
+ store: .init(
+ initialState: .init(
+ availableAPIKeyNames: ["test1", "test2"]
+ ),
+ reducer: { APIKeyManagement() }
+ )
+ )
+ }
+}
+
+class APIKeySubmissionView_Preview: PreviewProvider {
+ static var previews: some View {
+ APIKeySubmissionView(
+ store: .init(
+ initialState: .init(),
+ reducer: { APIKeySubmission() }
+ )
+ )
+ }
+}
+
diff --git a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManangement.swift b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManangement.swift
new file mode 100644
index 00000000..2756ce1e
--- /dev/null
+++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManangement.swift
@@ -0,0 +1,82 @@
+import ComposableArchitecture
+import Foundation
+
+@Reducer
+struct APIKeyManagement {
+ @ObservableState
+ struct State: Equatable {
+ var availableAPIKeyNames: [String] = []
+ @Presents var apiKeySubmission: APIKeySubmission.State?
+ }
+
+ enum Action: Equatable {
+ case appear
+ case closeButtonClicked
+ case addButtonClicked
+ case deleteButtonClicked(name: String)
+ case refreshAvailableAPIKeyNames
+
+ case apiKeySubmission(PresentationAction)
+ }
+
+ @Dependency(\.toast) var toast
+ @Dependency(\.apiKeyKeychain) var keychain
+
+ var body: some ReducerOf {
+ Reduce { state, action in
+ switch action {
+ case .appear:
+ if isPreview { return .none }
+
+ return .run { send in
+ await send(.refreshAvailableAPIKeyNames)
+ }
+ case .closeButtonClicked:
+ return .none
+
+ case .addButtonClicked:
+ state.apiKeySubmission = .init()
+
+ return .none
+
+ case let .deleteButtonClicked(name):
+ do {
+ try keychain.remove(name)
+ return .run { send in
+ await send(.refreshAvailableAPIKeyNames)
+ }
+ } catch {
+ toast(error.localizedDescription, .error)
+ return .none
+ }
+
+ case .refreshAvailableAPIKeyNames:
+ do {
+ let pairs = try keychain.getAll()
+ state.availableAPIKeyNames = Array(pairs.keys).sorted()
+ } catch {
+ toast(error.localizedDescription, .error)
+ }
+
+ return .none
+
+ case .apiKeySubmission(.presented(.saveFinished)):
+ state.apiKeySubmission = nil
+ return .run { send in
+ await send(.refreshAvailableAPIKeyNames)
+ }
+
+ case .apiKeySubmission(.presented(.cancelButtonClicked)):
+ state.apiKeySubmission = nil
+ return .none
+
+ case .apiKeySubmission:
+ return .none
+ }
+ }
+ .ifLet(\.$apiKeySubmission, action: \.apiKeySubmission) {
+ APIKeySubmission()
+ }
+ }
+}
+
diff --git a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyPicker.swift b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyPicker.swift
new file mode 100644
index 00000000..57e853d4
--- /dev/null
+++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyPicker.swift
@@ -0,0 +1,50 @@
+import ComposableArchitecture
+import SwiftUI
+
+struct APIKeyPicker: View {
+ @Perception.Bindable var store: StoreOf
+
+ var body: some View {
+ WithPerceptionTracking {
+ HStack {
+ Picker(
+ selection: $store.apiKeyName,
+ content: {
+ Text("No API Key").tag("")
+ if store.availableAPIKeyNames.isEmpty {
+ Text("No API key found, please add a new one โ")
+ }
+
+ if !store.availableAPIKeyNames.contains(store.apiKeyName),
+ !store.apiKeyName.isEmpty
+ {
+ Text("Key not found: \(store.apiKeyName)")
+ .tag(store.apiKeyName)
+ }
+
+ ForEach(store.availableAPIKeyNames, id: \.self) { name in
+ Text(name).tag(name)
+ }
+
+ },
+ label: { Text("API Key") }
+ )
+
+ Button(action: { store.send(.manageAPIKeysButtonClicked) }) {
+ Text(Image(systemName: "key"))
+ }
+ }.sheet(isPresented: $store.isAPIKeyManagementPresented) {
+ WithPerceptionTracking {
+ APIKeyManagementView(store: store.scope(
+ state: \.apiKeyManagement,
+ action: \.apiKeyManagement
+ ))
+ }
+ }
+ .onAppear {
+ store.send(.appear)
+ }
+ }
+ }
+}
+
diff --git a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySelection.swift b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySelection.swift
new file mode 100644
index 00000000..47e8b33b
--- /dev/null
+++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySelection.swift
@@ -0,0 +1,58 @@
+import Foundation
+import SwiftUI
+import ComposableArchitecture
+
+@Reducer
+struct APIKeySelection {
+ @ObservableState
+ struct State: Equatable {
+ var apiKeyName: String = ""
+ var availableAPIKeyNames: [String] {
+ apiKeyManagement.availableAPIKeyNames
+ }
+ var apiKeyManagement: APIKeyManagement.State = .init()
+ var isAPIKeyManagementPresented: Bool = false
+ }
+
+ enum Action: Equatable, BindableAction {
+ case appear
+ case manageAPIKeysButtonClicked
+
+ case binding(BindingAction)
+ case apiKeyManagement(APIKeyManagement.Action)
+ }
+
+ @Dependency(\.toast) var toast
+ @Dependency(\.apiKeyKeychain) var keychain
+
+ var body: some ReducerOf {
+ BindingReducer()
+
+ Scope(state: \.apiKeyManagement, action: \.apiKeyManagement) {
+ APIKeyManagement()
+ }
+
+ Reduce { state, action in
+ switch action {
+ case .appear:
+ return .run { send in
+ await send(.apiKeyManagement(.refreshAvailableAPIKeyNames))
+ }
+
+ case .manageAPIKeysButtonClicked:
+ state.isAPIKeyManagementPresented = true
+ return .none
+
+ case .binding:
+ return .none
+
+ case .apiKeyManagement(.closeButtonClicked):
+ state.isAPIKeyManagementPresented = false
+ return .none
+
+ case .apiKeyManagement:
+ return .none
+ }
+ }
+ }
+}
diff --git a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySubmission.swift b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySubmission.swift
new file mode 100644
index 00000000..8fe390ee
--- /dev/null
+++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySubmission.swift
@@ -0,0 +1,61 @@
+import ComposableArchitecture
+import Foundation
+
+@Reducer
+struct APIKeySubmission {
+ @ObservableState
+ struct State: Equatable {
+ var name: String = ""
+ var key: String = ""
+ }
+
+ enum Action: Equatable, BindableAction {
+ case binding(BindingAction)
+ case saveButtonClicked
+ case cancelButtonClicked
+ case saveFinished
+ }
+
+ @Dependency(\.toast) var toast
+ @Dependency(\.apiKeyKeychain) var keychain
+
+ enum E: Error, LocalizedError {
+ case nameIsEmpty
+ case keyIsEmpty
+ }
+
+ var body: some ReducerOf {
+ BindingReducer()
+
+ Reduce { state, action in
+ switch action {
+ case .saveButtonClicked:
+ do {
+ guard !state.name.isEmpty else { throw E.nameIsEmpty }
+ guard !state.key.isEmpty else { throw E.keyIsEmpty }
+
+ try keychain.update(
+ state.key,
+ key: state.name.trimmingCharacters(in: .whitespacesAndNewlines)
+ )
+ return .run { send in
+ await send(.saveFinished)
+ }
+ } catch {
+ toast(error.localizedDescription, .error)
+ return .none
+ }
+
+ case .cancelButtonClicked:
+ return .none
+
+ case .saveFinished:
+ return .none
+
+ case .binding:
+ return .none
+ }
+ }
+ }
+}
+
diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift
new file mode 100644
index 00000000..f0c673e5
--- /dev/null
+++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift
@@ -0,0 +1,342 @@
+import AIModel
+import ComposableArchitecture
+import Dependencies
+import Keychain
+import OpenAIService
+import Preferences
+import SwiftUI
+import Toast
+
+@Reducer
+struct ChatModelEdit {
+ @ObservableState
+ struct State: Equatable, Identifiable {
+ var id: 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 {
+ case binding(BindingAction)
+ case appear
+ case saveButtonClicked
+ case cancelButtonClicked
+ case refreshAvailableModelNames
+ case testButtonClicked
+ case testSucceeded(String)
+ case testFailed(String)
+ case checkSuggestedMaxTokens
+ case selectModelFormat(ModelFormat)
+ case apiKeySelection(APIKeySelection.Action)
+ case baseURLSelection(BaseURLSelection.Action)
+ }
+
+ enum ModelFormat: CaseIterable {
+ case openAI
+ case azureOpenAI
+ case googleAI
+ case ollama
+ case claude
+ case gitHubCopilot
+ case openAICompatible
+ case deepSeekOpenAICompatible
+ case openRouterOpenAICompatible
+ case grokOpenAICompatible
+ case mistralOpenAICompatible
+
+ init(_ format: ChatModel.Format) {
+ switch format {
+ case .openAI:
+ self = .openAI
+ case .azureOpenAI:
+ self = .azureOpenAI
+ case .googleAI:
+ self = .googleAI
+ case .ollama:
+ self = .ollama
+ case .claude:
+ self = .claude
+ case .openAICompatible:
+ self = .openAICompatible
+ case .gitHubCopilot:
+ self = .gitHubCopilot
+ }
+ }
+ }
+
+ var toast: (String, ToastType) -> Void {
+ @Dependency(\.namespacedToast) var toast
+ return {
+ toast($0, $1, "ChatModelEdit")
+ }
+ }
+
+ @Dependency(\.apiKeyKeychain) var keychain
+
+ var body: some ReducerOf {
+ BindingReducer()
+
+ Scope(state: \.apiKeySelection, action: \.apiKeySelection) {
+ APIKeySelection()
+ }
+
+ Scope(state: \.baseURLSelection, action: \.baseURLSelection) {
+ BaseURLSelection()
+ }
+
+ Reduce { state, action in
+ switch action {
+ case .appear:
+ return .run { send in
+ await send(.refreshAvailableModelNames)
+ await send(.checkSuggestedMaxTokens)
+ }
+
+ case .saveButtonClicked:
+ return .none
+
+ case .cancelButtonClicked:
+ return .none
+
+ case .testButtonClicked:
+ guard !state.isTesting else { return .none }
+ state.isTesting = true
+ let model = ChatModel(state: state)
+ return .run { send in
+ do {
+ 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))
+ }
+ }
+
+ case let .testSucceeded(message):
+ state.isTesting = false
+ toast(message.trimmingCharacters(in: .whitespacesAndNewlines), .info)
+ return .none
+
+ case let .testFailed(message):
+ state.isTesting = false
+ toast(message.trimmingCharacters(in: .whitespacesAndNewlines), .error)
+ return .none
+
+ case .refreshAvailableModelNames:
+ if state.format == .openAI {
+ state.availableModelNames = ChatGPTModel.allCases.map(\.rawValue)
+ }
+
+ return .none
+
+ case .checkSuggestedMaxTokens:
+ 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
+ }
+
+ 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):
+ return .run { send in
+ await send(.refreshAvailableModelNames)
+ await send(.checkSuggestedMaxTokens)
+ }
+
+ case .binding(\.modelName):
+ return .run { send in
+ await send(.checkSuggestedMaxTokens)
+ }
+
+ case .binding:
+ return .none
+ }
+ }
+ }
+}
+
+extension ChatModel {
+ init(state: ChatModelEdit.State) {
+ self.init(
+ id: state.id,
+ name: state.name,
+ format: state.format,
+ info: .init(
+ apiKeyName: state.apiKeyName,
+ baseURL: state.baseURL.trimmingCharacters(in: .whitespacesAndNewlines),
+ isFullURL: state.isFullURL,
+ maxTokens: state.maxTokens,
+ 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
new file mode 100644
index 00000000..d16b7556
--- /dev/null
+++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift
@@ -0,0 +1,660 @@
+import AIModel
+import ComposableArchitecture
+import OpenAIService
+import Preferences
+import SwiftUI
+
+@MainActor
+struct ChatModelEditView: View {
+ @Perception.Bindable var store: StoreOf
+
+ var body: some View {
+ WithPerceptionTracking {
+ ScrollView {
+ VStack(spacing: 0) {
+ Form {
+ NameTextField(store: store)
+ FormatPicker(store: store)
+
+ switch store.format {
+ case .openAI:
+ OpenAIForm(store: store)
+ case .azureOpenAI:
+ AzureOpenAIForm(store: store)
+ case .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()
+
+ Divider()
+
+ HStack {
+ HStack(spacing: 8) {
+ Button("Test") {
+ store.send(.testButtonClicked)
+ }
+ .disabled(store.isTesting)
+
+ if store.isTesting {
+ ProgressView()
+ .controlSize(.small)
+ }
+ }
+
+ CustomBodyEdit(store: store)
+ .disabled({
+ switch store.format {
+ case .openAI, .openAICompatible, .claude:
+ return false
+ default:
+ return true
+ }
+ }())
+ CustomHeaderEdit(store: store)
+ .disabled({
+ switch store.format {
+ case .openAI, .openAICompatible, .ollama, .gitHubCopilot, .claude:
+ return false
+ default:
+ return true
+ }
+ }())
+
+ Spacer()
+
+ Button("Cancel") {
+ store.send(.cancelButtonClicked)
+ }
+ .keyboardShortcut(.cancelAction)
+
+ Button(action: { store.send(.saveButtonClicked) }) {
+ Text("Save")
+ }
+ .keyboardShortcut(.defaultAction)
+ }
+ .padding()
+ }
+ }
+ .textFieldStyle(.roundedBorder)
+ .onAppear {
+ store.send(.appear)
+ }
+ .fixedSize(horizontal: false, vertical: true)
+ .handleToast(namespace: "ChatModelEdit")
+ }
+ }
+
+ struct NameTextField: View {
+ @Perception.Bindable var store: StoreOf
+ var body: some View {
+ WithPerceptionTracking {
+ TextField("Name", text: $store.name)
+ }
+ }
+ }
+
+ 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(.menu)
+ }
+ }
+ }
+
+ struct BaseURLTextField: View {
+ let store: StoreOf
+ var title: String = "Base URL"
+ let prompt: Text?
+ @ViewBuilder var trailingContent: () -> V
+
+ var body: some View {
+ WithPerceptionTracking {
+ BaseURLPicker(
+ title: title,
+ prompt: prompt,
+ store: store.scope(
+ state: \.baseURLSelection,
+ action: \.baseURLSelection
+ ),
+ trailingContent: trailingContent
+ )
+ }
+ }
+ }
+
+ struct SupportsFunctionCallingToggle: View {
+ @Perception.Bindable var store: StoreOf
+ var body: some View {
+ WithPerceptionTracking {
+ Toggle(
+ "Supports Function Calling",
+ isOn: $store.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()
+ }
+ }
+ }
+
+ 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("Context Window")
+ .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)
+
+ if let max = store.suggestedMaxTokens {
+ Text("Max: \(max)")
+ }
+ }
+ }
+ }
+ }
+
+ struct ApiKeyNamePicker: View {
+ let store: StoreOf
+ var body: some View {
+ WithPerceptionTracking {
+ APIKeyPicker(store: store.scope(
+ state: \.apiKeySelection,
+ action: \.apiKeySelection
+ ))
+ }
+ }
+ }
+
+ struct CustomBodyEdit: View {
+ @Perception.Bindable var store: StoreOf
+ @State private var isEditing = false
+ @Dependency(\.namespacedToast) var toast
+
+ var body: some View {
+ Button("Custom Body") {
+ isEditing = true
+ }
+ .sheet(isPresented: $isEditing) {
+ WithPerceptionTracking {
+ VStack {
+ TextEditor(text: $store.customBody)
+ .font(Font.system(.body, design: .monospaced))
+ .padding(4)
+ .frame(minHeight: 120)
+ .multilineTextAlignment(.leading)
+ .overlay(
+ RoundedRectangle(cornerRadius: 4)
+ .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
+ )
+ .handleToast(namespace: "CustomBodyEdit")
+
+ Text(
+ "The custom body will be added to the request body. Please use it to add parameters that are not yet available in the form. It should be a valid JSON object."
+ )
+ .foregroundColor(.secondary)
+ .font(.callout)
+ .padding(.bottom)
+
+ Button(action: {
+ if store.customBody.trimmingCharacters(in: .whitespacesAndNewlines)
+ .isEmpty
+ {
+ isEditing = false
+ return
+ }
+ guard let _ = try? JSONSerialization
+ .jsonObject(with: store.customBody.data(using: .utf8) ?? Data())
+ else {
+ toast("Invalid JSON object", .error, "CustomBodyEdit")
+ return
+ }
+ isEditing = false
+ }) {
+ Text("Done")
+ }
+ .keyboardShortcut(.defaultAction)
+ }
+ .padding()
+ .frame(width: 600, height: 500)
+ .background(Color(nsColor: .windowBackgroundColor))
+ }
+ }
+ }
+ }
+
+ struct CustomHeaderEdit: View {
+ @Perception.Bindable var store: StoreOf
+ @State private var isEditing = false
+
+ var body: some View {
+ Button("Custom Headers") {
+ isEditing = true
+ }
+ .sheet(isPresented: $isEditing) {
+ WithPerceptionTracking {
+ CustomHeaderSettingsView(headers: $store.customHeaders)
+ }
+ }
+ }
+ }
+
+ struct OpenAIForm: View {
+ @Perception.Bindable var store: StoreOf
+ var body: some View {
+ 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."
+ )
+ }
+ .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(store: store)
+ SupportsFunctionCallingToggle(store: store)
+
+ Toggle(isOn: $store.supportsImages) {
+ Text("Supports Images")
+ }
+ }
+ }
+ }
+
+ 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)
+
+ 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")
+ }
+
+ 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)
+ }
+ }
+ }
+
+ 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")
+ }
+
+ 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(store: store)
+ SupportsFunctionCallingToggle(store: store)
+
+ Toggle(isOn: $store.enforceMessageOrder) {
+ Text("Enforce message order to be user/assistant alternated")
+ }
+
+ Toggle(isOn: $store.openAICompatibleSupportsMultipartMessageContent) {
+ Text("Support multi-part message content")
+ }
+
+ Toggle(isOn: $store.supportsImages) {
+ Text("Supports Images")
+ }
+
+ VStack(alignment: .leading, spacing: 8) {
+ Text(Image(systemName: "exclamationmark.triangle.fill")) + Text(
+ " Please login in the GitHub Copilot settings to use the model."
+ )
+
+ Text(Image(systemName: "exclamationmark.triangle.fill")) + Text(
+ " This will call the APIs directly, which may not be allowed by GitHub. But it's used in other popular apps like Zed."
+ )
+ }
+ .dynamicHeightTextInFormWorkaround()
+ .padding(.vertical)
+ }
+ }
+ }
+}
+
+#Preview("OpenAI") {
+ 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
new file mode 100644
index 00000000..64eadd57
--- /dev/null
+++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagement.swift
@@ -0,0 +1,167 @@
+import AIModel
+import ComposableArchitecture
+import Keychain
+import Preferences
+import SwiftUI
+
+extension ChatModel: ManageableAIModel {
+ var formatName: String {
+ switch format {
+ 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"
+ }
+ }
+
+ @ViewBuilder
+ var infoDescriptors: some View {
+ Text(info.modelName)
+
+ if !info.baseURL.isEmpty {
+ Image(systemName: "line.diagonal")
+ Text(info.baseURL)
+ }
+
+ Image(systemName: "line.diagonal")
+
+ Text("\(info.maxTokens) tokens")
+
+ Image(systemName: "line.diagonal")
+
+ Text(
+ "function calling \(info.supportsFunctionCalling ? Image(systemName: "checkmark.square") : Image(systemName: "xmark.square"))"
+ )
+ }
+}
+
+@Reducer
+struct ChatModelManagement: AIModelManagement {
+ typealias Model = ChatModel
+
+ @ObservableState
+ struct State: Equatable, AIModelManagementState {
+ typealias Model = ChatModel
+ var models: IdentifiedArrayOf = []
+ @Presents var editingModel: ChatModelEdit.State?
+ var selectedModelId: String? { editingModel?.id }
+ }
+
+ enum Action: Equatable, AIModelManagementAction {
+ typealias Model = ChatModel
+ case appear
+ case createModel
+ case removeModel(id: Model.ID)
+ case selectModel(id: Model.ID)
+ case duplicateModel(id: Model.ID)
+ case moveModel(from: IndexSet, to: Int)
+ case chatModelItem(PresentationAction)
+ }
+
+ @Dependency(\.toast) var toast
+ @Dependency(\.userDefaults) var userDefaults
+
+ var body: some ReducerOf {
+ Reduce { state, action in
+ switch action {
+ case .appear:
+ if isPreview { return .none }
+ state.models = .init(
+ userDefaults.value(for: \.chatModels),
+ id: \.id,
+ uniquingIDsWith: { a, _ in a }
+ )
+
+ return .none
+
+ case .createModel:
+ state.editingModel = .init(
+ id: UUID().uuidString,
+ name: "New Model",
+ format: .openAI
+ )
+ return .none
+
+ case let .removeModel(id):
+ state.models.remove(id: id)
+ persist(state)
+ return .none
+
+ case let .selectModel(id):
+ guard let model = state.models[id: id] else { return .none }
+ state.editingModel = model.toState()
+ return .none
+
+ case let .duplicateModel(id):
+ guard var model = state.models[id: id] else { return .none }
+ model.id = UUID().uuidString
+ model.name += " (Copy)"
+
+ if let index = state.models.index(id: id) {
+ state.models.insert(model, at: index + 1)
+ } else {
+ state.models.append(model)
+ }
+ persist(state)
+ return .none
+
+ case let .moveModel(from, to):
+ state.models.move(fromOffsets: from, toOffset: to)
+ persist(state)
+ return .none
+
+ case .chatModelItem(.presented(.saveButtonClicked)):
+ guard let editingModel = state.editingModel, validateModel(editingModel)
+ else { return .none }
+
+ if let index = state.models
+ .firstIndex(where: { $0.id == editingModel.id })
+ {
+ state.models[index] = .init(state: editingModel)
+ } else {
+ state.models.append(.init(state: editingModel))
+ }
+ persist(state)
+ return .run { send in
+ await send(.chatModelItem(.dismiss))
+ }
+
+ case .chatModelItem(.presented(.cancelButtonClicked)):
+ return .run { send in
+ await send(.chatModelItem(.dismiss))
+ }
+
+ case .chatModelItem:
+ return .none
+ }
+ }.ifLet(\.$editingModel, action: \.chatModelItem) {
+ ChatModelEdit()
+ }
+ }
+
+ func persist(_ state: State) {
+ let models = state.models
+ userDefaults.set(Array(models), for: \.chatModels)
+ }
+
+ func validateModel(_ chatModel: ChatModelEdit.State) -> Bool {
+ guard !chatModel.name.isEmpty else {
+ toast("Model name cannot be empty", .error)
+ return false
+ }
+ guard !chatModel.id.isEmpty else {
+ toast("Model ID cannot be empty", .error)
+ return false
+ }
+
+ guard !chatModel.modelName.isEmpty else {
+ toast("Model name cannot be empty", .error)
+ return false
+ }
+ return true
+ }
+}
+
diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagementView.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagementView.swift
new file mode 100644
index 00000000..e81b4a97
--- /dev/null
+++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagementView.swift
@@ -0,0 +1,85 @@
+import AIModel
+import ComposableArchitecture
+import SwiftUI
+
+struct ChatModelManagementView: View {
+ @Perception.Bindable var store: StoreOf
+
+ var body: some View {
+ WithPerceptionTracking {
+ AIModelManagementView(store: store)
+ .sheet(item: $store.scope(
+ state: \.editingModel,
+ action: \.chatModelItem
+ )) { store in
+ ChatModelEditView(store: store)
+ .frame(width: 800)
+ }
+ }
+ }
+}
+
+// MARK: - Previews
+
+class ChatModelManagementView_Previews: PreviewProvider {
+ static var previews: some View {
+ ChatModelManagementView(
+ store: .init(
+ initialState: .init(
+ models: IdentifiedArray(uniqueElements: [
+ ChatModel(
+ id: "1",
+ name: "Test Model",
+ format: .openAI,
+ info: .init(
+ apiKeyName: "key",
+ baseURL: "google.com",
+ maxTokens: 3000,
+ supportsFunctionCalling: true,
+ modelName: "gpt-3.5-turbo"
+ )
+ ),
+ ChatModel(
+ id: "2",
+ name: "Test Model 2",
+ format: .azureOpenAI,
+ info: .init(
+ apiKeyName: "key",
+ baseURL: "apple.com",
+ maxTokens: 3000,
+ supportsFunctionCalling: false,
+ modelName: "gpt-3.5-turbo"
+ )
+ ),
+ 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() }
+ )
+ )
+ }
+}
+
diff --git a/Core/Sources/HostApp/AccountSettings/CodeiumView.swift b/Core/Sources/HostApp/AccountSettings/CodeiumView.swift
new file mode 100644
index 00000000..e60af2a8
--- /dev/null
+++ b/Core/Sources/HostApp/AccountSettings/CodeiumView.swift
@@ -0,0 +1,340 @@
+import CodeiumService
+import Foundation
+import SharedUIComponents
+import SwiftUI
+
+struct CodeiumView: View {
+ class ViewModel: ObservableObject {
+ let codeiumAuthService = CodeiumAuthService()
+ let installationManager = CodeiumInstallationManager()
+ @Published var isSignedIn: Bool
+ @Published var installationStatus: CodeiumInstallationManager.InstallationStatus
+ @Published var installationStep: CodeiumInstallationManager.InstallationStep?
+ @AppStorage(\.codeiumVerboseLog) var codeiumVerboseLog
+ @AppStorage(\.codeiumEnterpriseMode) var codeiumEnterpriseMode
+ @AppStorage(\.codeiumPortalUrl) var codeiumPortalUrl
+ @AppStorage(\.codeiumApiUrl) var codeiumApiUrl
+ @AppStorage(\.codeiumIndexEnabled) var indexEnabled
+
+ init() {
+ isSignedIn = codeiumAuthService.isSignedIn
+ installationStatus = .notInstalled
+ Task { @MainActor in
+ installationStatus = await installationManager.checkInstallation()
+ }
+ }
+
+ init(
+ isSignedIn: Bool,
+ installationStatus: CodeiumInstallationManager.InstallationStatus,
+ installationStep: CodeiumInstallationManager.InstallationStep?
+ ) {
+ assert(isPreview)
+ self.isSignedIn = isSignedIn
+ self.installationStatus = installationStatus
+ self.installationStep = installationStep
+ }
+
+ func generateAuthURL() -> URL {
+ if codeiumEnterpriseMode && (codeiumPortalUrl != "") {
+ return URL(
+ string: codeiumPortalUrl +
+ "/profile?response_type=token&redirect_uri=show-auth-token&state=\(UUID().uuidString)&scope=openid%20profile%20email&redirect_parameters_type=query"
+ )!
+ }
+
+ return URL(
+ string: "https://www.codeium.com/profile?response_type=token&redirect_uri=show-auth-token&state=\(UUID().uuidString)&scope=openid%20profile%20email&redirect_parameters_type=query"
+ )!
+ }
+
+ func signIn(token: String) async throws {
+ try await codeiumAuthService.signIn(token: token)
+ Task { @MainActor in isSignedIn = true }
+ }
+
+ func signOut() async throws {
+ try await codeiumAuthService.signOut()
+ Task { @MainActor in isSignedIn = false }
+ }
+
+ func refreshInstallationStatus() {
+ Task { @MainActor in
+ installationStatus = await installationManager.checkInstallation()
+ }
+ }
+
+ func install() async throws {
+ defer { refreshInstallationStatus() }
+ do {
+ for try await step in installationManager.installLatestVersion() {
+ Task { @MainActor in
+ self.installationStep = step
+ }
+ }
+ Task {
+ try await Task.sleep(nanoseconds: 1_000_000_000)
+ Task { @MainActor in
+ self.installationStep = nil
+ }
+ }
+ } catch {
+ Task { @MainActor in
+ installationStep = nil
+ }
+ throw error
+ }
+ }
+
+ func uninstall() {
+ Task {
+ defer { refreshInstallationStatus() }
+ try await installationManager.uninstall()
+ }
+ }
+ }
+
+ @StateObject var viewModel = ViewModel()
+ @Environment(\.toast) var toast
+ @State var isSignInPanelPresented = false
+
+ var installButton: some View {
+ Button(action: {
+ Task {
+ do {
+ try await viewModel.install()
+ } catch {
+ toast(error.localizedDescription, .error)
+ }
+ }
+ }) {
+ Text("Install")
+ }
+ .disabled(viewModel.installationStep != nil)
+ }
+
+ var updateButton: some View {
+ Button(action: {
+ Task {
+ do {
+ try await viewModel.install()
+ } catch {
+ toast(error.localizedDescription, .error)
+ }
+ }
+ }) {
+ Text("Update")
+ }
+ .disabled(viewModel.installationStep != nil)
+ }
+
+ var uninstallButton: some View {
+ Button(action: {
+ viewModel.uninstall()
+ }) {
+ Text("Uninstall")
+ }
+ .disabled(viewModel.installationStep != nil)
+ }
+
+ var body: some View {
+ VStack(alignment: .leading) {
+ SubSection(title: Text("Codeium Language Server")) {
+ switch viewModel.installationStatus {
+ case .notInstalled:
+ HStack {
+ Text("Language Server Version: Not Installed")
+ installButton
+ }
+ case let .installed(version):
+ HStack {
+ Text("Language Server Version: \(version)")
+ uninstallButton
+ }
+ case let .outdated(current: current, latest: latest, _):
+ HStack {
+ Text("Language Server Version: \(current) (Update Available: \(latest))")
+ uninstallButton
+ updateButton
+ }
+ case let .unsupported(current: current, latest: latest):
+ HStack {
+ Text("Language Server Version: \(current) (Supported Version: \(latest))")
+ uninstallButton
+ updateButton
+ }
+ }
+
+ if viewModel.isSignedIn {
+ Text("Status: Signed In")
+
+ Button(action: {
+ Task {
+ do {
+ try await viewModel.signOut()
+ } catch {
+ toast(error.localizedDescription, .error)
+ }
+ }
+ }) {
+ Text("Sign Out")
+ }
+ } else {
+ Text("Status: Not Signed In")
+
+ Button(action: {
+ isSignInPanelPresented = true
+ }) {
+ Text("Sign In")
+ }
+ }
+ }
+ .sheet(isPresented: $isSignInPanelPresented) {
+ CodeiumSignInView(viewModel: viewModel, isPresented: $isSignInPanelPresented)
+ }
+ .onChange(of: viewModel.installationStep) { newValue in
+ if let step = newValue {
+ switch step {
+ case .downloading:
+ toast("Downloading..", .info)
+ case .uninstalling:
+ toast("Uninstalling old version..", .info)
+ case .decompressing:
+ toast("Decompressing..", .info)
+ case .done:
+ toast("Done!", .info)
+ }
+ }
+ }
+
+ SubSection(title: Text("Indexing")) {
+ Form {
+ Toggle("Enable Indexing", isOn: $viewModel.indexEnabled)
+ }
+ }
+
+ 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)
+ }
+ }
+
+ SettingsDivider("Advanced")
+
+ Form {
+ Toggle("Verbose Log", isOn: $viewModel.codeiumVerboseLog)
+ }
+ }
+ }
+}
+
+struct CodeiumSignInView: View {
+ let viewModel: CodeiumView.ViewModel
+ @Binding var isPresented: Bool
+ @Environment(\.openURL) var openURL
+ @Environment(\.toast) var toast
+ @State var isGeneratingKey = false
+ @State var token = ""
+
+ var body: some View {
+ VStack {
+ Text(
+ "You will be redirected to codeium.com. Please paste the generated token below and click the \"Sign In\" button."
+ )
+
+ TextEditor(text: $token)
+ .font(Font.system(.body, design: .monospaced))
+ .padding(4)
+ .frame(minHeight: 120)
+ .multilineTextAlignment(.leading)
+ .overlay(
+ RoundedRectangle(cornerRadius: 4)
+ .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
+ )
+
+ HStack {
+ Spacer()
+
+ Button(action: {
+ isPresented = false
+ }) {
+ Text("Cancel")
+ }
+
+ Button(action: {
+ isGeneratingKey = true
+ Task {
+ do {
+ try await viewModel.signIn(token: token)
+ isGeneratingKey = false
+ isPresented = false
+ } catch {
+ isGeneratingKey = false
+ toast(error.localizedDescription, .error)
+ }
+ }
+ }) {
+ Text(isGeneratingKey ? "Signing In.." : "Sign In")
+ }
+ .disabled(isGeneratingKey)
+ .keyboardShortcut(.defaultAction)
+ }
+ }
+ .padding()
+ .onAppear {
+ openURL(viewModel.generateAuthURL())
+ }
+ }
+}
+
+struct CodeiumView_Previews: PreviewProvider {
+ class TestViewModel: CodeiumView.ViewModel {
+ override func generateAuthURL() -> URL {
+ return URL(string: "about:blank")!
+ }
+
+ override func signIn(token: String) async throws {}
+
+ override func signOut() async throws {}
+
+ override func refreshInstallationStatus() {}
+
+ override func install() async throws {}
+
+ override func uninstall() {}
+ }
+
+ static var previews: some View {
+ 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)
+ }
+ }
+}
+
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/EmbeddingModel.swift b/Core/Sources/HostApp/AccountSettings/EmbeddingModel.swift
new file mode 100644
index 00000000..f2c917d6
--- /dev/null
+++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModel.swift
@@ -0,0 +1,2 @@
+import SwiftUI
+import Keychain
diff --git a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift
new file mode 100644
index 00000000..f057be21
--- /dev/null
+++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift
@@ -0,0 +1,272 @@
+import AIModel
+import ComposableArchitecture
+import Dependencies
+import Keychain
+import OpenAIService
+import Preferences
+import SwiftUI
+import Toast
+
+@Reducer
+struct EmbeddingModelEdit {
+ @ObservableState
+ struct State: Equatable, Identifiable {
+ var id: 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 {
+ case binding(BindingAction)
+ case appear
+ case saveButtonClicked
+ case cancelButtonClicked
+ case refreshAvailableModelNames
+ 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(\.apiKeyKeychain) var keychain
+
+ var body: some ReducerOf {
+ BindingReducer()
+
+ Scope(state: \.apiKeySelection, action: \.apiKeySelection) {
+ APIKeySelection()
+ }
+
+ Scope(state: \.baseURLSelection, action: \.baseURLSelection) {
+ BaseURLSelection()
+ }
+
+ Reduce { state, action in
+ switch action {
+ case .appear:
+ return .run { send in
+ await send(.refreshAvailableModelNames)
+ await send(.checkSuggestedMaxTokens)
+ }
+
+ case .saveButtonClicked:
+ return .none
+
+ case .cancelButtonClicked:
+ return .none
+
+ case .testButtonClicked:
+ guard !state.isTesting else { return .none }
+ state.isTesting = true
+ let dimensions = state.dimensions
+ let model = EmbeddingModel(
+ id: state.id,
+ name: state.name,
+ format: state.format,
+ 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 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))
+ }
+ }
+
+ case let .testSucceeded(message):
+ state.isTesting = false
+ toast(message, .info)
+ return .none
+
+ case let .testFailed(message):
+ state.isTesting = false
+ toast(message, .error)
+ return .none
+
+ case .refreshAvailableModelNames:
+ if state.format == .openAI {
+ state.availableModelNames = ChatGPTModel.allCases.map(\.rawValue)
+ }
+
+ return .none
+
+ case .checkSuggestedMaxTokens:
+ guard state.format == .openAI,
+ let knownModel = OpenAIEmbeddingModel(rawValue: state.modelName)
+ else {
+ state.suggestedMaxTokens = nil
+ 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:
+ return .none
+
+ case .baseURLSelection:
+ return .none
+
+ case .binding(\.format):
+ return .run { send in
+ await send(.refreshAvailableModelNames)
+ await send(.checkSuggestedMaxTokens)
+ }
+
+ case .binding(\.modelName):
+ return .run { send in
+ await send(.checkSuggestedMaxTokens)
+ }
+
+ case .binding:
+ return .none
+ }
+ }
+ }
+}
+
+extension EmbeddingModel {
+ init(state: EmbeddingModelEdit.State) {
+ self.init(
+ id: state.id,
+ name: state.name,
+ format: state.format,
+ info: .init(
+ apiKeyName: state.apiKeyName,
+ baseURL: state.baseURL.trimmingCharacters(in: .whitespacesAndNewlines),
+ isFullURL: state.isFullURL,
+ maxTokens: state.maxTokens,
+ dimensions: state.dimensions,
+ modelName: state.modelName.trimmingCharacters(in: .whitespacesAndNewlines),
+ ollamaInfo: .init(keepAlive: state.ollamaKeepAlive),
+ 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
new file mode 100644
index 00000000..46f4effd
--- /dev/null
+++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift
@@ -0,0 +1,459 @@
+import AIModel
+import ComposableArchitecture
+import Preferences
+import SwiftUI
+
+@MainActor
+struct EmbeddingModelEditView: View {
+ @Perception.Bindable var store: StoreOf
+
+ var body: some View {
+ WithPerceptionTracking {
+ ScrollView {
+ VStack(spacing: 0) {
+ Form {
+ NameTextField(store: store)
+ FormatPicker(store: store)
+
+ switch store.format {
+ case .openAI:
+ OpenAIForm(store: store)
+ case .azureOpenAI:
+ AzureOpenAIForm(store: store)
+ case .openAICompatible:
+ OpenAICompatibleForm(store: store)
+ case .ollama:
+ OllamaForm(store: store)
+ case .gitHubCopilot:
+ GitHubCopilotForm(store: store)
+ }
+ }
+ .padding()
+
+ Divider()
+
+ HStack {
+ HStack(spacing: 8) {
+ Button("Test") {
+ store.send(.testButtonClicked)
+ }
+ .disabled(store.isTesting)
+
+ if store.isTesting {
+ ProgressView()
+ .controlSize(.small)
+ }
+ }
+
+ Spacer()
+
+ Button("Cancel") {
+ store.send(.cancelButtonClicked)
+ }
+ .keyboardShortcut(.cancelAction)
+
+ Button(action: { store.send(.saveButtonClicked) }) {
+ Text("Save")
+ }
+ .keyboardShortcut(.defaultAction)
+ }
+ .padding()
+ }
+ }
+ .textFieldStyle(.roundedBorder)
+ .onAppear {
+ store.send(.appear)
+ }
+ .fixedSize(horizontal: false, vertical: true)
+ .handleToast(namespace: "EmbeddingModelEdit")
+ }
+ }
+
+ struct NameTextField: View {
+ @Perception.Bindable var store: StoreOf
+ var body: some View {
+ WithPerceptionTracking {
+ TextField("Name", text: $store.name)
+ }
+ }
+ }
+
+ 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(.menu)
+ }
+ }
+ }
+
+ struct BaseURLTextField: View {
+ let store: StoreOf
+ var title: String = "Base URL"
+ let prompt: Text?
+ @ViewBuilder var trailingContent: () -> V
+
+ var body: some View {
+ WithPerceptionTracking {
+ BaseURLPicker(
+ title: title,
+ prompt: prompt,
+ store: store.scope(
+ state: \.baseURLSelection,
+ action: \.baseURLSelection
+ ),
+ trailingContent: trailingContent
+ )
+ }
+ }
+ }
+
+ 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)
+
+ if let max = store.suggestedMaxTokens {
+ Text("Max: \(max)")
+ }
+ }
+ }
+ }
+ }
+
+ struct DimensionsTextField: View {
+ @Perception.Bindable var store: StoreOf
+
+ var body: some View {
+ WithPerceptionTracking {
+ HStack {
+ let textFieldBinding = Binding(
+ get: { String(store.dimensions) },
+ set: {
+ if let selectionDimensions = Int($0) {
+ $store.dimensions.wrappedValue = selectionDimensions
+ } else {
+ $store.dimensions.wrappedValue = 0
+ }
+ }
+ )
+
+ TextField(text: textFieldBinding) {
+ Text("Dimensions")
+ .multilineTextAlignment(.trailing)
+ }
+ .overlay(alignment: .trailing) {
+ Stepper(
+ value: $store.dimensions,
+ in: 0...Int.max,
+ step: 100
+ ) {
+ EmptyView()
+ }
+ }
+ .foregroundColor({
+ if store.dimensions <= 0 {
+ return .red
+ }
+ return .primary
+ }() as Color)
+ }
+
+ Text("If you are not sure, run test to get the correct value.")
+ .font(.caption)
+ .dynamicHeightTextInFormWorkaround()
+ }
+ }
+ }
+
+ struct ApiKeyNamePicker: View {
+ let store: StoreOf
+ var body: some View {
+ WithPerceptionTracking {
+ APIKeyPicker(store: store.scope(
+ state: \.apiKeySelection,
+ action: \.apiKeySelection
+ ))
+ }
+ }
+ }
+
+ struct OpenAIForm: View {
+ @Perception.Bindable var store: StoreOf
+
+ 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."
+ )
+ }
+ .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(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)
+
+ Button("Custom Headers") {
+ isEditingCustomHeader.toggle()
+ }
+ }.sheet(isPresented: $isEditingCustomHeader) {
+ CustomHeaderSettingsView(headers: $store.customHeaders)
+ }
+ }
+ }
+
+ struct OllamaForm: View {
+ @Perception.Bindable var store: StoreOf
+ @State var isEditingCustomHeader = false
+
+ var body: some View {
+ WithPerceptionTracking {
+ BaseURLTextField(store: store, prompt: Text("http://127.0.0.1:11434")) {
+ Text("/api/embeddings")
+ }
+
+ ApiKeyNamePicker(store: store)
+
+ TextField("Model Name", text: $store.modelName)
+
+ MaxTokensTextField(store: store)
+ DimensionsTextField(store: store)
+
+ WithPerceptionTracking {
+ TextField(text: $store.ollamaKeepAlive, prompt: Text("Default Value")) {
+ 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."
+ )
+
+ 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)
+ }
+ }
+ }
+}
+
+class EmbeddingModelManagementView_Editing_Previews: PreviewProvider {
+ static var previews: some View {
+ EmbeddingModelEditView(
+ store: .init(
+ initialState: 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: { EmbeddingModelEdit() }
+ )
+ )
+ }
+}
+
diff --git a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagement.swift b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagement.swift
new file mode 100644
index 00000000..156f58ac
--- /dev/null
+++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagement.swift
@@ -0,0 +1,159 @@
+import AIModel
+import ComposableArchitecture
+import Keychain
+import Preferences
+import SwiftUI
+
+extension EmbeddingModel: ManageableAIModel {
+ var formatName: String {
+ switch format {
+ case .openAI: return "OpenAI"
+ case .azureOpenAI: return "Azure OpenAI"
+ case .openAICompatible: return "OpenAI Compatible"
+ case .ollama: return "Ollama"
+ case .gitHubCopilot: return "GitHub Copilot"
+ }
+ }
+
+ @ViewBuilder
+ var infoDescriptors: some View {
+ Text(info.modelName)
+
+ if !info.baseURL.isEmpty {
+ Image(systemName: "line.diagonal")
+ Text(info.baseURL)
+ }
+
+ Image(systemName: "line.diagonal")
+
+ Text("\(info.maxTokens) tokens")
+ }
+}
+
+@Reducer
+struct EmbeddingModelManagement: AIModelManagement {
+ typealias Model = EmbeddingModel
+
+ @ObservableState
+ struct State: Equatable, AIModelManagementState {
+ typealias Model = EmbeddingModel
+ var models: IdentifiedArrayOf = []
+ @Presents var editingModel: EmbeddingModelEdit.State?
+ var selectedModelId: Model.ID? { editingModel?.id }
+ }
+
+ enum Action: Equatable, AIModelManagementAction {
+ typealias Model = EmbeddingModel
+ case appear
+ case createModel
+ case removeModel(id: Model.ID)
+ case selectModel(id: Model.ID)
+ case duplicateModel(id: Model.ID)
+ case moveModel(from: IndexSet, to: Int)
+ case embeddingModelItem(PresentationAction)
+ }
+
+ @Dependency(\.toast) var toast
+ @Dependency(\.userDefaults) var userDefaults
+
+ var body: some ReducerOf {
+ Reduce { state, action in
+ switch action {
+ case .appear:
+ if isPreview { return .none }
+ state.models = .init(
+ userDefaults.value(for: \.embeddingModels),
+ id: \.id,
+ uniquingIDsWith: { a, _ in a }
+ )
+
+ return .none
+
+ case .createModel:
+ state.editingModel = .init(
+ id: UUID().uuidString,
+ name: "New Model",
+ format: .openAI
+ )
+ return .none
+
+ case let .removeModel(id):
+ state.models.remove(id: id)
+ persist(state)
+ return .none
+
+ case let .selectModel(id):
+ guard let model = state.models[id: id] else { return .none }
+ state.editingModel = model.toState()
+ return .none
+
+ case let .duplicateModel(id):
+ guard var model = state.models[id: id] else { return .none }
+ model.id = UUID().uuidString
+ model.name += " (Copy)"
+
+ if let index = state.models.index(id: id) {
+ state.models.insert(model, at: index + 1)
+ } else {
+ state.models.append(model)
+ }
+ persist(state)
+ return .none
+
+ case let .moveModel(from, to):
+ state.models.move(fromOffsets: from, toOffset: to)
+ persist(state)
+ return .none
+
+ case .embeddingModelItem(.presented(.saveButtonClicked)):
+ guard let editingModel = state.editingModel, validateModel(editingModel)
+ else { return .none }
+
+ if let index = state.models
+ .firstIndex(where: { $0.id == editingModel.id })
+ {
+ state.models[index] = .init(state: editingModel)
+ } else {
+ state.models.append(.init(state: editingModel))
+ }
+ persist(state)
+ return .run { send in
+ await send(.embeddingModelItem(.dismiss))
+ }
+
+ case .embeddingModelItem(.presented(.cancelButtonClicked)):
+ return .run { send in
+ await send(.embeddingModelItem(.dismiss))
+ }
+
+ case .embeddingModelItem:
+ return .none
+ }
+ }.ifLet(\.$editingModel, action: \.embeddingModelItem) {
+ EmbeddingModelEdit()
+ }
+ }
+
+ func persist(_ state: State) {
+ let models = state.models
+ userDefaults.set(Array(models), for: \.embeddingModels)
+ }
+
+ func validateModel(_ chatModel: EmbeddingModelEdit.State) -> Bool {
+ guard !chatModel.name.isEmpty else {
+ toast("Model name cannot be empty", .error)
+ return false
+ }
+ guard !chatModel.id.isEmpty else {
+ toast("Model ID cannot be empty", .error)
+ return false
+ }
+
+ guard !chatModel.modelName.isEmpty else {
+ toast("Model name cannot be empty", .error)
+ return false
+ }
+ return true
+ }
+}
+
diff --git a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagementView.swift b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagementView.swift
new file mode 100644
index 00000000..e251af10
--- /dev/null
+++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagementView.swift
@@ -0,0 +1,81 @@
+import AIModel
+import ComposableArchitecture
+import SwiftUI
+
+struct EmbeddingModelManagementView: View {
+ @Perception.Bindable var store: StoreOf
+
+ var body: some View {
+ WithPerceptionTracking {
+ AIModelManagementView(store: store)
+ .sheet(item: $store.scope(
+ state: \.editingModel,
+ action: \.embeddingModelItem
+ )) { store in
+ EmbeddingModelEditView(store: store)
+ .frame(width: 800)
+ }
+ }
+ }
+}
+
+// MARK: - Previews
+
+class EmbeddingModelManagementView_Previews: PreviewProvider {
+ static var previews: some View {
+ EmbeddingModelManagementView(
+ store: .init(
+ initialState: .init(
+ models: IdentifiedArray(uniqueElements: [
+ EmbeddingModel(
+ id: "1",
+ name: "Test Model",
+ format: .openAI,
+ info: .init(
+ apiKeyName: "key",
+ baseURL: "google.com",
+ maxTokens: 3000,
+ modelName: "gpt-3.5-turbo"
+ )
+ ),
+ EmbeddingModel(
+ id: "2",
+ name: "Test Model 2",
+ format: .azureOpenAI,
+ info: .init(
+ apiKeyName: "key",
+ baseURL: "apple.com",
+ maxTokens: 3000,
+ modelName: "gpt-3.5-turbo"
+ )
+ ),
+ 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() }
+ )
+ )
+ }
+}
+
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
new file mode 100644
index 00000000..ec627113
--- /dev/null
+++ b/Core/Sources/HostApp/AccountSettings/GitHubCopilotView.swift
@@ -0,0 +1,469 @@
+import AppKit
+import Client
+import GitHubCopilotService
+import Preferences
+import SharedUIComponents
+import SuggestionBasic
+import SwiftUI
+
+struct GitHubCopilotView: View {
+ static var copilotAuthService: GitHubCopilotAuthServiceType?
+
+ class Settings: ObservableObject {
+ @AppStorage(\.nodePath) var nodePath: String
+ @AppStorage(\.runNodeWith) var runNodeWith
+ @AppStorage("username") var username: String = ""
+ @AppStorage(\.gitHubCopilotVerboseLog) var gitHubCopilotVerboseLog
+ @AppStorage(\.gitHubCopilotProxyHost) var gitHubCopilotProxyHost
+ @AppStorage(\.gitHubCopilotProxyPort) var gitHubCopilotProxyPort
+ @AppStorage(\.gitHubCopilotProxyUsername) var gitHubCopilotProxyUsername
+ @AppStorage(\.gitHubCopilotProxyPassword) var gitHubCopilotProxyPassword
+ @AppStorage(\.gitHubCopilotUseStrictSSL) var gitHubCopilotUseStrictSSL
+ @AppStorage(\.gitHubCopilotEnterpriseURI) var gitHubCopilotEnterpriseURI
+ @AppStorage(\.gitHubCopilotPretendIDEToBeVSCode) var pretendIDEToBeVSCode
+ @AppStorage(\.disableGitHubCopilotSettingsAutoRefreshOnAppear)
+ var disableGitHubCopilotSettingsAutoRefreshOnAppear
+ @AppStorage(\.gitHubCopilotLoadKeyChainCertificates)
+ var gitHubCopilotLoadKeyChainCertificates
+ @AppStorage(\.gitHubCopilotModelId) var gitHubCopilotModelId
+ init() {}
+ }
+
+ class ViewModel: ObservableObject {
+ let installationManager = GitHubCopilotInstallationManager()
+
+ @Published var installationStatus: GitHubCopilotInstallationManager.InstallationStatus?
+ @Published var installationStep: GitHubCopilotInstallationManager.InstallationStep?
+
+ init() {}
+
+ init(
+ installationStatus: GitHubCopilotInstallationManager.InstallationStatus,
+ installationStep: GitHubCopilotInstallationManager.InstallationStep?
+ ) {
+ assert(isPreview)
+ self.installationStatus = installationStatus
+ self.installationStep = installationStep
+ }
+
+ func refreshInstallationStatus() {
+ Task { @MainActor in
+ installationStatus = installationManager.checkInstallation()
+ }
+ }
+
+ func install() async throws {
+ defer { refreshInstallationStatus() }
+ do {
+ for try await step in installationManager.installLatestVersion() {
+ Task { @MainActor in
+ self.installationStep = step
+ }
+ }
+ Task {
+ try await Task.sleep(nanoseconds: 1_000_000_000)
+ Task { @MainActor in
+ self.installationStep = nil
+ }
+ }
+ } catch {
+ Task { @MainActor in
+ installationStep = nil
+ }
+ throw error
+ }
+ }
+
+ func uninstall() {
+ Task {
+ defer { refreshInstallationStatus() }
+ try await installationManager.uninstall()
+ Task { @MainActor in
+ GitHubCopilotView.copilotAuthService = nil
+ }
+ }
+ }
+ }
+
+ @Environment(\.openURL) var openURL
+ @Environment(\.toast) var toast
+ @StateObject var settings = Settings()
+ @StateObject var viewModel = ViewModel()
+
+ @State var status: GitHubCopilotAccountStatus?
+ @State var userCode: String?
+ @State var version: String?
+ @State var isRunningAction: Bool = false
+ @State var isUserCodeCopiedAlertPresented = false
+
+ func getGitHubCopilotAuthService() throws -> GitHubCopilotAuthServiceType {
+ if let service = Self.copilotAuthService { return service }
+ let service = try GitHubCopilotAuthService()
+ Self.copilotAuthService = service
+ return service
+ }
+
+ var installButton: some View {
+ Button(action: {
+ Task {
+ do {
+ try await viewModel.install()
+ } catch {
+ toast(error.localizedDescription, .error)
+ }
+ }
+ }) {
+ Text("Install")
+ }
+ .disabled(viewModel.installationStep != nil)
+ }
+
+ var updateButton: some View {
+ Button(action: {
+ Task {
+ do {
+ try await viewModel.install()
+ } catch {
+ toast(error.localizedDescription, .error)
+ }
+ }
+ }) {
+ Text("Update")
+ }
+ .disabled(viewModel.installationStep != nil)
+ }
+
+ var uninstallButton: some View {
+ Button(action: {
+ viewModel.uninstall()
+ }) {
+ Text("Uninstall")
+ }
+ .disabled(viewModel.installationStep != nil)
+ }
+
+ var body: some View {
+ HStack {
+ VStack(alignment: .leading, spacing: 8) {
+ 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"
+ )
+ .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")
+ }
+
+ 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.")
+ }
+ }
+ .lineLimit(10)
+ .foregroundColor(.secondary)
+ .font(.callout)
+ .dynamicHeightTextInFormWorkaround()
+
+ Toggle(isOn: $settings.gitHubCopilotLoadKeyChainCertificates) {
+ Text("Load certificates in keychain")
+ }
+ }
+ }
+
+ SubSection(
+ title: Text("GitHub Copilot Language Server")
+ ) {
+ HStack {
+ switch viewModel.installationStatus {
+ case .none:
+ Text("Copilot.Vim Version: Loading..")
+ case .notInstalled:
+ Text("Copilot.Vim Version: Not Installed")
+ installButton
+ case let .installed(version):
+ Text("Copilot.Vim Version: \(version)")
+ uninstallButton
+ case let .outdated(version, latest, _):
+ Text("Copilot.Vim Version: \(version) (Update Available: \(latest))")
+ updateButton
+ uninstallButton
+ case let .unsupported(version, latest):
+ Text("Copilot.Vim Version: \(version) (Supported Version: \(latest))")
+ updateButton
+ uninstallButton
+ }
+ }
+
+ Text("Language Server Version: \(version ?? "Loading..")")
+
+ Text("Status: \(status?.description ?? "Loading..")")
+
+ HStack(alignment: .center) {
+ Button("Refresh") {
+ viewModel.refreshInstallationStatus()
+ checkStatus()
+ }
+ if status == .notSignedIn {
+ Button("Sign In") { signIn() }
+ .alert(isPresented: $isUserCodeCopiedAlertPresented) {
+ Alert(
+ title: Text(userCode ?? ""),
+ message: Text(
+ "The user code is pasted into your clipboard, please paste it in the opened website to login.\nAfter that, click \"Confirm Sign-in\" to finish."
+ ),
+ dismissButton: .default(Text("OK"))
+ )
+ }
+ Button("Confirm Sign-in") { confirmSignIn() }
+ }
+ if status == .ok || status == .alreadySignedIn ||
+ status == .notAuthorized
+ {
+ Button("Sign Out") { signOut() }
+ }
+ if isRunningAction {
+ ActivityIndicatorView()
+ }
+ }
+ .opacity(isRunningAction ? 0.8 : 1)
+ .disabled(isRunningAction)
+
+ Button("Refresh configurations") {
+ refreshConfiguration()
+ }
+
+ Form {
+ GitHubCopilotModelPicker(
+ title: "Chat Model Name",
+ gitHubCopilotModelId: $settings.gitHubCopilotModelId
+ )
+ }
+ }
+
+ SettingsDivider("Advanced")
+
+ Form {
+ Toggle("Verbose log", isOn: $settings.gitHubCopilotVerboseLog)
+ Toggle("Pretend IDE to be VSCode", isOn: $settings.pretendIDEToBeVSCode)
+ }
+
+ SettingsDivider("Enterprise")
+
+ Form {
+ TextField(
+ text: $settings.gitHubCopilotEnterpriseURI,
+ prompt: Text("Leave it blank if non is available.")
+ ) {
+ Text("Auth provider URL")
+ }
+ }
+
+ SettingsDivider("Proxy")
+
+ Form {
+ TextField(
+ text: $settings.gitHubCopilotProxyHost,
+ prompt: Text("xxx.xxx.xxx.xxx, leave it blank to disable proxy.")
+ ) {
+ Text("Proxy host")
+ }
+ TextField(text: $settings.gitHubCopilotProxyPort, prompt: Text("80")) {
+ Text("Proxy port")
+ }
+ TextField(text: $settings.gitHubCopilotProxyUsername) {
+ Text("Proxy username")
+ }
+ SecureField(text: $settings.gitHubCopilotProxyPassword) {
+ Text("Proxy password")
+ }
+ Toggle("Proxy strict SSL", isOn: $settings.gitHubCopilotUseStrictSSL)
+ }
+ }
+ Spacer()
+ }.onAppear {
+ if isPreview { return }
+ if settings.disableGitHubCopilotSettingsAutoRefreshOnAppear { return }
+ viewModel.refreshInstallationStatus()
+ checkStatus()
+ }.onChange(of: settings.runNodeWith) { _ in
+ Self.copilotAuthService = nil
+ }.onChange(of: settings.nodePath) { _ in
+ Self.copilotAuthService = nil
+ }.onChange(of: viewModel.installationStep) { newValue in
+ if let step = newValue {
+ switch step {
+ case .downloading:
+ toast("Downloading..", .info)
+ case .uninstalling:
+ toast("Uninstalling old version..", .info)
+ case .decompressing:
+ toast("Decompressing..", .info)
+ case .done:
+ toast("Done!", .info)
+ checkStatus()
+ }
+ }
+ }
+ .textFieldStyle(.roundedBorder)
+ }
+
+ func checkStatus() {
+ Task {
+ isRunningAction = true
+ defer { isRunningAction = false }
+ do {
+ let service = try getGitHubCopilotAuthService()
+ status = try await service.checkStatus()
+ version = try await service.version()
+ isRunningAction = false
+
+ if status != .ok, status != .notSignedIn {
+ toast(
+ "GitHub Copilot status is not \"ok\". Please check if you have a valid GitHub Copilot subscription.",
+ .error
+ )
+ }
+ } catch {
+ toast(error.localizedDescription, .error)
+ }
+ }
+ }
+
+ func signIn() {
+ Task {
+ isRunningAction = true
+ defer { isRunningAction = false }
+ do {
+ let service = try getGitHubCopilotAuthService()
+ let (uri, userCode) = try await service.signInInitiate()
+ self.userCode = userCode
+ guard let url = URL(string: uri) else {
+ toast("Verification URI is incorrect.", .error)
+ return
+ }
+ let pasteboard = NSPasteboard.general
+ pasteboard.declareTypes([NSPasteboard.PasteboardType.string], owner: nil)
+ pasteboard.setString(userCode, forType: NSPasteboard.PasteboardType.string)
+ toast("Usercode \(userCode) already copied!", .info)
+ openURL(url)
+ isUserCodeCopiedAlertPresented = true
+ } catch {
+ toast(error.localizedDescription, .error)
+ }
+ }
+ }
+
+ func confirmSignIn() {
+ Task {
+ isRunningAction = true
+ defer { isRunningAction = false }
+ do {
+ let service = try getGitHubCopilotAuthService()
+ guard let userCode else {
+ toast("Usercode is empty.", .error)
+ return
+ }
+ let (username, status) = try await service.signInConfirm(userCode: userCode)
+ self.settings.username = username
+ self.status = status
+ } catch {
+ toast(error.localizedDescription, .error)
+ }
+ }
+ }
+
+ func signOut() {
+ Task {
+ isRunningAction = true
+ defer { isRunningAction = false }
+ do {
+ let service = try getGitHubCopilotAuthService()
+ status = try await service.signOut()
+ } catch {
+ toast(error.localizedDescription, .error)
+ }
+ }
+ }
+
+ 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 {
+ func makeNSView(context _: Context) -> NSProgressIndicator {
+ let progressIndicator = NSProgressIndicator()
+ progressIndicator.style = .spinning
+ progressIndicator.appearance = NSAppearance(named: .vibrantLight)
+ progressIndicator.controlSize = .small
+ progressIndicator.startAnimation(nil)
+ return progressIndicator
+ }
+
+ func updateNSView(_: NSProgressIndicator, context _: Context) {
+ // No-op
+ }
+}
+
+struct CopilotView_Previews: PreviewProvider {
+ static var previews: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ GitHubCopilotView(status: .notSignedIn, version: "1.0.0")
+ GitHubCopilotView(status: .alreadySignedIn, isRunningAction: true)
+ }
+ .padding(.all, 8)
+ }
+}
+
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
new file mode 100644
index 00000000..2c1fd2d7
--- /dev/null
+++ b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/AIModelManagementVIew.swift
@@ -0,0 +1,281 @@
+import AIModel
+import ComposableArchitecture
+import PlusFeatureFlag
+import SharedUIComponents
+import SwiftUI
+
+protocol AIModelManagementAction {
+ associatedtype Model: ManageableAIModel
+ static var appear: Self { get }
+ static var createModel: Self { get }
+ static func removeModel(id: Model.ID) -> Self
+ static func selectModel(id: Model.ID) -> Self
+ static func duplicateModel(id: Model.ID) -> Self
+ static func moveModel(from: IndexSet, to: Int) -> Self
+}
+
+protocol AIModelManagementState: Equatable {
+ associatedtype Model: ManageableAIModel
+ var models: IdentifiedArrayOf { get }
+ var selectedModelId: Model.ID? { get }
+}
+
+protocol AIModelManagement: Reducer where
+ Action: AIModelManagementAction,
+ State: AIModelManagementState & ObservableState,
+ Action.Model == Self.Model,
+ State.Model == Self.Model
+{
+ associatedtype Model: ManageableAIModel
+}
+
+protocol ManageableAIModel: Identifiable {
+ associatedtype V: View
+ var name: String { get }
+ var formatName: String { get }
+ var infoDescriptors: V { get }
+}
+
+struct AIModelManagementView: View
+ where Management.Model == Model
+{
+ @Perception.Bindable var store: StoreOf
+
+ var body: some View {
+ 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 = store.models.count >= 2
+
+ Button(disabled ? "Add More Model (Plus)" : "Add Model") {
+ store.send(.createModel)
+ }.disabled(disabled)
+ }
+ }.padding(4)
+
+ Divider()
+
+ ModelList(store: store)
+ }
+ .onAppear {
+ store.send(.appear)
+ }
+ }
+ }
+
+ struct ModelList: View {
+ @Perception.Bindable var store: StoreOf
+
+ var body: some View {
+ WithPerceptionTracking {
+ List {
+ ForEach(store.models) { model in
+ WithPerceptionTracking {
+ let isSelected = store.selectedModelId == model.id
+ HStack(spacing: 4) {
+ Image(systemName: "line.3.horizontal")
+
+ Button(action: {
+ store.send(.selectModel(id: model.id))
+ }) {
+ Cell(model: model, isSelected: isSelected)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .contextMenu {
+ Button("Duplicate") {
+ store.send(.duplicateModel(id: model.id))
+ }
+ Button("Remove") {
+ store.send(.removeModel(id: model.id))
+ }
+ }
+ }
+ }
+ }
+ .onMove(perform: { indices, newOffset in
+ store.send(.moveModel(from: indices, to: newOffset))
+ })
+ .modify { view in
+ if #available(macOS 13.0, *) {
+ view.listRowSeparator(.hidden).listSectionSeparator(.hidden)
+ } else {
+ view
+ }
+ }
+ }
+ .removeBackground()
+ .listStyle(.plain)
+ .listRowInsets(EdgeInsets())
+ .overlay {
+ if store.models.isEmpty {
+ Text("No model found, please add a new one.")
+ .foregroundColor(.secondary)
+ }
+ }
+ }
+ }
+ }
+
+ struct Cell: View {
+ let model: Model
+ let isSelected: Bool
+ @State var isHovered: Bool = false
+
+ var body: some View {
+ HStack {
+ VStack(alignment: .leading, spacing: 2) {
+ HStack {
+ Text(model.formatName)
+ .foregroundColor(isSelected ? .white : .primary)
+ .font(.subheadline.bold())
+ .padding(.vertical, 2)
+ .padding(.horizontal, 4)
+ .background {
+ RoundedRectangle(cornerRadius: 4)
+ .fill(
+ isSelected
+ ? .white.opacity(0.2)
+ : Color.primary.opacity(0.1)
+ )
+ }
+
+ Text(model.name)
+ .font(.headline)
+ }
+
+ HStack(spacing: 4) {
+ model.infoDescriptors
+ }
+ .font(.subheadline)
+ .opacity(0.7)
+ .padding(.leading, 2)
+ }
+ Spacer()
+ }
+ .onHover(perform: {
+ isHovered = $0
+ })
+ .padding(.vertical, 8)
+ .padding(.horizontal, 8)
+ .background {
+ RoundedRectangle(cornerRadius: 8, style: .continuous)
+ .fill({
+ switch (isSelected, isHovered) {
+ case (true, _):
+ return Color.accentColor
+ case (_, true):
+ return Color.primary.opacity(0.1)
+ case (_, false):
+ return Color.clear
+ }
+ }() as Color)
+ }
+ .foregroundColor(isSelected ? .white : .primary)
+ .animation(.easeInOut(duration: 0.1), value: isSelected)
+ .animation(.easeInOut(duration: 0.1), value: isHovered)
+ }
+ }
+}
+
+// MARK: - Previews
+
+class AIModelManagement_Previews: PreviewProvider {
+ static var previews: some View {
+ AIModelManagementView(
+ store: .init(
+ initialState: .init(
+ models: IdentifiedArray(uniqueElements: [
+ ChatModel(
+ id: "1",
+ name: "Test Model",
+ format: .openAI,
+ info: .init(
+ apiKeyName: "key",
+ baseURL: "google.com",
+ maxTokens: 3000,
+ supportsFunctionCalling: true,
+ modelName: "gpt-3.5-turbo"
+ )
+ ),
+ ChatModel(
+ id: "2",
+ name: "Test Model 2",
+ format: .azureOpenAI,
+ info: .init(
+ apiKeyName: "key",
+ baseURL: "apple.com",
+ maxTokens: 3000,
+ supportsFunctionCalling: false,
+ modelName: "gpt-3.5-turbo"
+ )
+ ),
+ 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() }
+ )
+ )
+ }
+}
+
+class AIModelManagement_Empty_Previews: PreviewProvider {
+ static var previews: some View {
+ AIModelManagementView(
+ store: .init(
+ initialState: .init(models: [] as IdentifiedArrayOf),
+ reducer: { ChatModelManagement() }
+ )
+ )
+ }
+}
+
+class AIModelManagement_Cell_Previews: PreviewProvider {
+ static var previews: some View {
+ AIModelManagementView.Cell(model: ChatModel(
+ id: "1",
+ name: "Test Model",
+ format: .openAI,
+ info: .init(
+ apiKeyName: "key",
+ baseURL: "google.com",
+ maxTokens: 3000,
+ supportsFunctionCalling: true,
+ modelName: "gpt-3.5-turbo"
+ )
+ ), isSelected: false)
+ }
+}
+
diff --git a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLPicker.swift b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLPicker.swift
new file mode 100644
index 00000000..9456946e
--- /dev/null
+++ b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLPicker.swift
@@ -0,0 +1,60 @@
+import ComposableArchitecture
+import SwiftUI
+
+struct BaseURLPicker: View {
+ let title: String
+ let prompt: Text?
+ @Perception.Bindable var store: StoreOf
+ @ViewBuilder let trailingContent: () -> TrailingContent
+
+ var body: some View {
+ 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("")
+
+ ForEach(store.availableBaseURLs, id: \.self) { baseURL in
+ Text(baseURL).tag(baseURL)
+ }
+ }
+ )
+ .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
new file mode 100644
index 00000000..502d79a7
--- /dev/null
+++ b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLSelection.swift
@@ -0,0 +1,53 @@
+import ComposableArchitecture
+import Foundation
+import Preferences
+import SwiftUI
+
+@Reducer
+struct BaseURLSelection {
+ @ObservableState
+ struct State: Equatable {
+ var baseURL: String = ""
+ var isFullURL: Bool = false
+ var availableBaseURLs: [String] = []
+ }
+
+ enum Action: Equatable, BindableAction {
+ case appear
+ case refreshAvailableBaseURLNames
+ case binding(BindingAction)
+ }
+
+ @Dependency(\.toast) var toast
+ @Dependency(\.userDefaults) var userDefaults
+
+ var body: some ReducerOf {
+ BindingReducer()
+
+ Reduce { state, action in
+ switch action {
+ case .appear:
+ return .run { send in
+ await send(.refreshAvailableBaseURLNames)
+ }
+
+ case .refreshAvailableBaseURLNames:
+ let chatModels = userDefaults.value(for: \.chatModels)
+ let embeddingModels = userDefaults.value(for: \.embeddingModels)
+ var allBaseURLs = Set(
+ chatModels.map(\.info.baseURL)
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+ + embeddingModels.map(\.info.baseURL)
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+ )
+ allBaseURLs.remove("")
+ state.availableBaseURLs = Array(allBaseURLs).sorted()
+ return .none
+
+ case .binding:
+ return .none
+ }
+ }
+ }
+}
+
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
new file mode 100644
index 00000000..884c58f0
--- /dev/null
+++ b/Core/Sources/HostApp/CustomCommandSettings/CustomCommand.swift
@@ -0,0 +1,132 @@
+import ComposableArchitecture
+import Foundation
+import PlusFeatureFlag
+import Preferences
+import SwiftUI
+import Toast
+
+@Reducer
+struct CustomCommandFeature {
+ @ObservableState
+ struct State: Equatable {
+ var editCustomCommand: EditCustomCommand.State?
+ }
+
+ let settings: CustomCommandView.Settings
+
+ enum Action: Equatable {
+ case createNewCommand
+ 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 ReducerOf {
+ Reduce { state, action in
+ switch action {
+ case .createNewCommand:
+ if !isFeatureAvailable(\.unlimitedCustomCommands),
+ settings.customCommands.count >= 10
+ {
+ toast("Upgrade to Plus to add more commands", .info)
+ return .none
+ }
+ state.editCustomCommand = EditCustomCommand.State(nil)
+ return .none
+ case let .editCommand(command):
+ state.editCustomCommand = EditCustomCommand.State(command)
+ return .none
+ case .editCustomCommand(.close):
+ state.editCustomCommand = nil
+ return .none
+ case let .deleteCommand(command):
+ settings.customCommands.removeAll(
+ where: { $0.id == command.id }
+ )
+ if state.editCustomCommand?.commandId == command.id {
+ state.editCustomCommand = nil
+ }
+ 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: \.editCustomCommand) {
+ EditCustomCommand(settings: settings)
+ }
+ }
+}
+
diff --git a/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift b/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift
new file mode 100644
index 00000000..033b9850
--- /dev/null
+++ b/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift
@@ -0,0 +1,357 @@
+import ComposableArchitecture
+import MarkdownUI
+import PlusFeatureFlag
+import Preferences
+import SharedUIComponents
+import SwiftUI
+import Toast
+
+extension List {
+ @ViewBuilder
+ func removeBackground() -> some View {
+ if #available(macOS 13.0, *) {
+ scrollContentBackground(.hidden)
+ .listRowBackground(EmptyView())
+ } else {
+ background(Color.clear)
+ .listRowBackground(EmptyView())
+ }
+ }
+}
+
+let customCommandStore = StoreOf(
+ initialState: .init(),
+ reducer: { CustomCommandFeature(settings: .init()) }
+)
+
+struct CustomCommandView: View {
+ final class Settings: ObservableObject {
+ @AppStorage(\.customCommands) var customCommands
+
+ init(customCommands: AppStorage<[CustomCommand]>? = nil) {
+ if let list = customCommands {
+ _customCommands = list
+ }
+ }
+ }
+
+ var store: StoreOf
+ @StateObject var settings = Settings()
+ @Environment(\.toast) var toast
+
+ var body: some View {
+ HStack(spacing: 0) {
+ LeftPanel(store: store, settings: settings)
+ Divider()
+ RightPanel(store: store)
+ }
+ }
+
+ 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))
+ }
+ }
+ }
+
+ 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)
+ }
+ }
+ }
+ }
+
+ return !jsonFiles.isEmpty
+ }
+ }
+
+ struct CommandButton: View {
+ @Perception.Bindable var store: StoreOf
+ let command: CustomCommand
+
+ var body: some View {
+ WithPerceptionTracking {
+ HStack(spacing: 4) {
+ Image(systemName: "line.3.horizontal")
+
+ 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("Modification")
+ case .singleRoundDialog:
+ Text("Single Round Dialog")
+ }
+ }
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .contentShape(Rectangle())
+ .onTapGesture {
+ store.send(.editCommand(command))
+ }
+ }
+ .padding(4)
+ .background {
+ RoundedRectangle(cornerRadius: 4)
+ .fill(
+ store.editCustomCommand?.commandId == command.id
+ ? Color.primary.opacity(0.05)
+ : Color.clear
+ )
+ }
+ .contextMenu {
+ Button("Remove") {
+ store.send(.deleteCommand(command))
+ }
+
+ Button("Export") {
+ store.send(.exportCommand(command))
+ }
+ }
+ }
+ }
+ }
+
+ 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()
+ }
+ }
+ }
+ }
+}
+
+struct CustomCommandTypeDescription: View {
+ let text: String
+ var body: some View {
+ ScrollView {
+ Markdown(text)
+ .lineLimit(nil)
+ .markdownTheme(
+ .gitHub
+ .text {
+ ForegroundColor(.secondary)
+ BackgroundColor(.clear)
+ FontSize(14)
+ }
+ .heading1 { conf in
+ VStack(alignment: .leading, spacing: 0) {
+ conf.label
+ .relativePadding(.bottom, length: .em(0.3))
+ .relativeLineSpacing(.em(0.125))
+ .markdownMargin(top: 24, bottom: 16)
+ .markdownTextStyle {
+ FontWeight(.semibold)
+ FontSize(.em(1.25))
+ }
+ Divider()
+ }
+ }
+ )
+ .padding()
+ .background(Color.primary.opacity(0.02), in: RoundedRectangle(cornerRadius: 8))
+ .overlay {
+ RoundedRectangle(cornerRadius: 8)
+ .stroke(style: .init(lineWidth: 1))
+ .foregroundColor(Color(nsColor: .separatorColor))
+ }
+ .padding()
+ }
+ }
+}
+
+// MARK: - Previews
+
+struct CustomCommandView_Preview: PreviewProvider {
+ static var previews: some View {
+ let settings = CustomCommandView.Settings(customCommands: .init(wrappedValue: [
+ .init(
+ commandId: "1",
+ name: "Explain Code",
+ feature: .chatWithSelection(
+ extraSystemPrompt: nil,
+ prompt: "Hello",
+ useExtraSystemPrompt: false
+ ),
+ ignoreExistingAttachments: false,
+ attachments: []
+ ),
+ .init(
+ commandId: "2",
+ name: "Refactor Code",
+ feature: .promptToCode(
+ extraSystemPrompt: nil,
+ prompt: "Refactor",
+ continuousMode: false,
+ generateDescription: true
+ ),
+ ignoreExistingAttachments: false,
+ attachments: []
+ ),
+ ], "CustomCommandView_Preview"))
+
+ return CustomCommandView(
+ store: .init(
+ initialState: .init(
+ editCustomCommand: .init(.init(.init(
+ commandId: "1",
+ name: "Explain Code",
+ feature: .chatWithSelection(
+ extraSystemPrompt: nil,
+ prompt: "Hello",
+ useExtraSystemPrompt: false
+ ),
+ ignoreExistingAttachments: false,
+ attachments: [] as [CustomCommand.Attachment]
+ )))
+ ),
+ reducer: { CustomCommandFeature(settings: settings) }
+ ),
+ settings: settings
+ )
+ }
+}
+
+struct CustomCommandView_NoEditing_Preview: PreviewProvider {
+ static var previews: some View {
+ let settings = CustomCommandView.Settings(customCommands: .init(wrappedValue: [
+ .init(
+ commandId: "1",
+ name: "Explain Code",
+ feature: .chatWithSelection(
+ extraSystemPrompt: nil,
+ prompt: "Hello",
+ useExtraSystemPrompt: false
+ ),
+ ignoreExistingAttachments: false,
+ attachments: []
+ ),
+ .init(
+ commandId: "2",
+ name: "Refactor Code",
+ feature: .promptToCode(
+ extraSystemPrompt: nil,
+ prompt: "Refactor",
+ continuousMode: false,
+ generateDescription: true
+ ),
+ ignoreExistingAttachments: false,
+ attachments: []
+ ),
+ ], "CustomCommandView_Preview"))
+
+ return CustomCommandView(
+ store: .init(
+ initialState: .init(
+ editCustomCommand: nil
+ ),
+ reducer: { CustomCommandFeature(settings: settings) }
+ ),
+ settings: settings
+ )
+ }
+}
+
diff --git a/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommand.swift b/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommand.swift
new file mode 100644
index 00000000..e927a9ff
--- /dev/null
+++ b/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommand.swift
@@ -0,0 +1,309 @@
+import ComposableArchitecture
+import Foundation
+import Preferences
+import SwiftUI
+
+@Reducer
+struct EditCustomCommand {
+ enum CommandType: Int, CaseIterable, Equatable {
+ case sendMessage
+ case promptToCode
+ case customChat
+ case singleRoundDialog
+ }
+
+ @ObservableState
+ struct State: Equatable {
+ var name: String = ""
+ var commandType: CommandType = .sendMessage
+ var isNewCommand: Bool = false
+ let commandId: String
+
+ var sendMessage = EditSendMessageCommand.State()
+ 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):
+ commandType = .sendMessage
+ sendMessage = .init(
+ extraSystemPrompt: extraSystemPrompt ?? "",
+ useExtraSystemPrompt: useExtraSystemPrompt ?? false,
+ prompt: prompt ?? ""
+ )
+ case .none:
+ commandType = .sendMessage
+ sendMessage = .init(
+ extraSystemPrompt: "",
+ useExtraSystemPrompt: false,
+ prompt: "Hello"
+ )
+ case let .customChat(systemPrompt, prompt):
+ commandType = .customChat
+ customChat = .init(
+ systemPrompt: systemPrompt ?? "",
+ prompt: prompt ?? ""
+ )
+ case let .singleRoundDialog(
+ systemPrompt,
+ overwriteSystemPrompt,
+ prompt,
+ receiveReplyInNotification
+ ):
+ commandType = .singleRoundDialog
+ singleRoundDialog = .init(
+ systemPrompt: systemPrompt ?? "",
+ overwriteSystemPrompt: overwriteSystemPrompt ?? false,
+ prompt: prompt ?? "",
+ receiveReplyInNotification: receiveReplyInNotification ?? false
+ )
+ case let .promptToCode(extraSystemPrompt, prompt, continuousMode, generateDescription):
+ commandType = .promptToCode
+ promptToCode = .init(
+ extraSystemPrompt: extraSystemPrompt ?? "",
+ prompt: prompt ?? "",
+ continuousMode: continuousMode ?? false,
+ generateDescription: generateDescription ?? true
+ )
+ }
+ }
+ }
+
+ enum Action: BindableAction, Equatable {
+ case saveCommand
+ case close
+ case binding(BindingAction)
+ case sendMessage(EditSendMessageCommand.Action)
+ 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 ReducerOf {
+ Scope(state: \.sendMessage, action: \.sendMessage) {
+ EditSendMessageCommand()
+ }
+
+ Scope(state: \.promptToCode, action: \.promptToCode) {
+ EditPromptToCodeCommand()
+ }
+
+ Scope(state: \.customChat, action: \.customChat) {
+ EditCustomChatCommand()
+ }
+
+ Scope(state: \.singleRoundDialog, action: \.singleRoundDialog) {
+ EditSingleRoundDialogCommand()
+ }
+
+ Scope(state: \.attachments, action: \.attachments) {
+ EditCustomCommandAttachment()
+ }
+
+ BindingReducer()
+
+ Reduce { state, action in
+ switch action {
+ case .saveCommand:
+ guard !state.name.isEmpty else {
+ toast("Command name cannot be empty.", .error)
+ return .none
+ }
+
+ let newCommand = CustomCommand(
+ commandId: state.commandId,
+ name: state.name,
+ feature: {
+ switch state.commandType {
+ case .sendMessage:
+ let state = state.sendMessage
+ return .chatWithSelection(
+ extraSystemPrompt: state.extraSystemPrompt,
+ prompt: state.prompt,
+ useExtraSystemPrompt: state.useExtraSystemPrompt
+ )
+ case .promptToCode:
+ let state = state.promptToCode
+ return .promptToCode(
+ extraSystemPrompt: state.extraSystemPrompt,
+ prompt: state.prompt,
+ continuousMode: state.continuousMode,
+ generateDescription: state.generateDescription
+ )
+ case .customChat:
+ let state = state.customChat
+ return .customChat(
+ systemPrompt: state.systemPrompt,
+ prompt: state.prompt
+ )
+ case .singleRoundDialog:
+ let state = state.singleRoundDialog
+ return .singleRoundDialog(
+ systemPrompt: state.systemPrompt,
+ overwriteSystemPrompt: state.overwriteSystemPrompt,
+ prompt: state.prompt,
+ receiveReplyInNotification: state.receiveReplyInNotification
+ )
+ }
+ }(),
+ ignoreExistingAttachments: state.attachments.ignoreExistingAttachments,
+ attachments: state.attachments.attachments
+ )
+
+ if state.isNewCommand {
+ settings.customCommands.append(newCommand)
+ state.isNewCommand = false
+ toast("The command is created.", .info)
+ } else {
+ if let index = settings.customCommands.firstIndex(where: {
+ $0.id == newCommand.id
+ }) {
+ settings.customCommands[index] = newCommand
+ } else {
+ settings.customCommands.append(newCommand)
+ }
+ toast("The command is updated.", .info)
+ }
+
+ return .none
+
+ case .close:
+ return .none
+
+ case .binding:
+ return .none
+ case .sendMessage:
+ return .none
+ case .promptToCode:
+ return .none
+ case .customChat:
+ 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
+ }
+ }
+ }
+}
+
+@Reducer
+struct EditSendMessageCommand {
+ @ObservableState
+ struct State: Equatable {
+ var extraSystemPrompt: String = ""
+ var useExtraSystemPrompt: Bool = false
+ var prompt: String = ""
+ }
+
+ enum Action: BindableAction, Equatable {
+ case binding(BindingAction)
+ }
+
+ var body: some ReducerOf {
+ BindingReducer()
+
+ Reduce { _, action in
+ switch action {
+ case .binding:
+ return .none
+ }
+ }
+ }
+}
+
+@Reducer
+struct EditPromptToCodeCommand {
+ @ObservableState
+ struct State: Equatable {
+ var extraSystemPrompt: String = ""
+ var prompt: String = ""
+ var continuousMode: Bool = false
+ var generateDescription: Bool = false
+ }
+
+ enum Action: BindableAction, Equatable {
+ case binding(BindingAction)
+ }
+
+ var body: some ReducerOf {
+ BindingReducer()
+ }
+}
+
+@Reducer
+struct EditCustomChatCommand {
+ @ObservableState
+ struct State: Equatable {
+ var systemPrompt: String = ""
+ var prompt: String = ""
+ }
+
+ enum Action: BindableAction, Equatable {
+ case binding(BindingAction)
+ }
+
+ var body: some ReducerOf {
+ BindingReducer()
+ }
+}
+
+@Reducer
+struct EditSingleRoundDialogCommand {
+ @ObservableState
+ struct State: Equatable {
+ var systemPrompt: String = ""
+ var overwriteSystemPrompt: Bool = false
+ var prompt: String = ""
+ var receiveReplyInNotification: Bool = false
+ }
+
+ enum Action: BindableAction, Equatable {
+ case binding(BindingAction)
+ }
+
+ var body: some ReducerOf {
+ BindingReducer()
+ }
+}
+
diff --git a/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift b/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift
new file mode 100644
index 00000000..e2304f8b
--- /dev/null
+++ b/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift
@@ -0,0 +1,419 @@
+import ComposableArchitecture
+import MarkdownUI
+import Preferences
+import SwiftUI
+
+@MainActor
+struct EditCustomCommandView: View {
+ @Environment(\.toast) var toast
+ @Perception.Bindable var store: StoreOf
+
+ init(store: StoreOf) {
+ self.store = store
+ }
+
+ var body: some View {
+ ScrollView {
+ Form {
+ sharedForm
+ featureSpecificForm
+ }.padding()
+ }.safeAreaInset(edge: .bottom) {
+ bottomBar
+ }
+ }
+
+ @ViewBuilder var sharedForm: some View {
+ WithPerceptionTracking {
+ TextField("Name", text: $store.name)
+
+ Picker("Command Type", selection: $store.commandType) {
+ ForEach(
+ EditCustomCommand.CommandType.allCases,
+ id: \.rawValue
+ ) { commandType in
+ Text({
+ switch commandType {
+ case .sendMessage:
+ return "Send Message"
+ case .promptToCode:
+ return "Modification"
+ case .customChat:
+ return "Custom Chat"
+ case .singleRoundDialog:
+ return "Single Round Dialog"
+ }
+ }() as String).tag(commandType)
+ }
+ }
+ }
+ }
+
+ @ViewBuilder var featureSpecificForm: some View {
+ WithPerceptionTracking {
+ switch store.commandType {
+ case .sendMessage:
+ EditSendMessageCommandView(
+ store: store.scope(
+ state: \.sendMessage,
+ action: \.sendMessage
+ ),
+ attachmentStore: store.scope(
+ state: \.attachments,
+ action: \.attachments
+ )
+ )
+ case .promptToCode:
+ EditPromptToCodeCommandView(
+ store: store.scope(
+ state: \.promptToCode,
+ action: \.promptToCode
+ )
+ )
+ case .customChat:
+ EditCustomChatCommandView(
+ store: store.scope(
+ state: \.customChat,
+ action: \.customChat
+ ),
+ attachmentStore: store.scope(
+ state: \.attachments,
+ action: \.attachments
+ )
+ )
+ case .singleRoundDialog:
+ EditSingleRoundDialogCommandView(
+ store: store.scope(
+ state: \.singleRoundDialog,
+ action: \.singleRoundDialog
+ )
+ )
+ }
+ }
+ }
+
+ @ViewBuilder var bottomBar: some View {
+ WithPerceptionTracking {
+ VStack {
+ Divider()
+
+ VStack(alignment: .trailing) {
+ Text(
+ "After renaming or adding a custom command, please restart Xcode to refresh the menu."
+ )
+ .foregroundStyle(.secondary)
+
+ HStack {
+ Spacer()
+ Button("Close") {
+ store.send(.close)
+ }
+
+ if store.isNewCommand {
+ Button("Add") {
+ store.send(.saveCommand)
+ }
+ } else {
+ Button("Save") {
+ store.send(.saveCommand)
+ }
+ }
+ }
+ }
+ .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"
+ }
+ }
+}
+
+struct EditSendMessageCommandView: View {
+ @Perception.Bindable var store: StoreOf
+ var attachmentStore: StoreOf
+
+ var body: some View {
+ WithPerceptionTracking {
+ VStack(alignment: .leading, spacing: 4) {
+ Toggle("Extra Context", isOn: $store.useExtraSystemPrompt)
+ EditableText(text: $store.extraSystemPrompt)
+ }
+ .padding(.vertical, 4)
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Send immediately")
+ EditableText(text: $store.prompt)
+ }
+ .padding(.vertical, 4)
+
+ CustomCommandAttachmentPickerView(store: attachmentStore)
+ .padding(.vertical, 4)
+ }
+ }
+}
+
+struct EditPromptToCodeCommandView: View {
+ @Perception.Bindable var store: StoreOf
+
+ var body: some View {
+ WithPerceptionTracking {
+ Toggle("Continuous Mode", isOn: $store.continuousMode)
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Extra Context")
+ EditableText(text: $store.extraSystemPrompt)
+ }
+ .padding(.vertical, 4)
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Instruction")
+ EditableText(text: $store.prompt)
+ }
+ .padding(.vertical, 4)
+ }
+ }
+}
+
+struct EditCustomChatCommandView: View {
+ @Perception.Bindable var store: StoreOf
+ var attachmentStore: StoreOf
+
+ var body: some View {
+ WithPerceptionTracking {
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Topic")
+ EditableText(text: $store.systemPrompt)
+ }
+ .padding(.vertical, 4)
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Send immediately")
+ EditableText(text: $store.prompt)
+ }
+ .padding(.vertical, 4)
+
+ CustomCommandAttachmentPickerView(store: attachmentStore)
+ .padding(.vertical, 4)
+ }
+ }
+}
+
+struct EditSingleRoundDialogCommandView: View {
+ @Perception.Bindable var store: StoreOf