diff --git a/Core/Package.resolved b/Core/Package.resolved index 95bd917e..47ef43c2 100644 --- a/Core/Package.resolved +++ b/Core/Package.resolved @@ -1,12 +1,21 @@ { "pins" : [ { - "identity" : "feedkit", + "identity" : "codablewrappers", "kind" : "remoteSourceControl", - "location" : "https://github.com/nmdias/FeedKit", + "location" : "https://github.com/GottaGetSwifty/CodableWrappers", "state" : { - "revision" : "68493a33d862c33c9a9f67ec729b3b7df1b20ade", - "version" : "9.1.2" + "revision" : "4eb46a4c656333e8514db8aad204445741de7d40", + "version" : "2.0.7" + } + }, + { + "identity" : "combine-schedulers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/combine-schedulers", + "state" : { + "revision" : "ec62f32d21584214a4b27c8cee2b2ad70ab2c38a", + "version" : "0.11.0" } }, { @@ -27,22 +36,13 @@ "version" : "1.0.5" } }, - { - "identity" : "gptencoder", - "kind" : "remoteSourceControl", - "location" : "https://github.com/alfianlosari/GPTEncoder", - "state" : { - "revision" : "a86968867ab4380e36b904a14c42215f71efe8b4", - "version" : "1.0.4" - } - }, { "identity" : "highlightr", "kind" : "remoteSourceControl", - "location" : "https://github.com/raspu/Highlightr", + "location" : "https://github.com/intitni/Highlightr", "state" : { - "revision" : "93199b9e434f04bda956a613af8f571933f9f037", - "version" : "2.1.2" + "branch" : "bump-highlight-js-version", + "revision" : "4ffbb1b0b721378263297cafea6f2838044eb1eb" } }, { @@ -50,8 +50,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ChimeHQ/JSONRPC", "state" : { - "revision" : "e0a30db87e70d31c821f99b9699c0bef61748aac", - "version" : "0.6.1" + "revision" : "5da978702aece6ba5c7879b0d253c180d61e4ef3", + "version" : "0.6.0" } }, { @@ -100,21 +100,30 @@ } }, { - "identity" : "splash", + "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/JohnSundell/Splash", + "location" : "https://github.com/pointfreeco/swift-case-paths", "state" : { - "branch" : "master", - "revision" : "2e3f17c2d09689c8bf175c4a84ff7f2ad3353301" + "revision" : "fc45e7b2cfece9dd80b5a45e6469ffe67fe67984", + "version" : "0.14.1" } }, { - "identity" : "swift-async-algorithms", + "identity" : "swift-clocks", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-async-algorithms", + "location" : "https://github.com/pointfreeco/swift-clocks", "state" : { - "revision" : "9cfed92b026c524674ed869a4ff2dcfdeedf8a2a", - "version" : "0.1.0" + "revision" : "0fbaebfc013715dab44d715a4d350ba37f297e4d", + "version" : "0.4.0" } }, { @@ -126,6 +135,51 @@ "version" : "1.0.4" } }, + { + "identity" : "swift-composable-architecture", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-composable-architecture", + "state" : { + "revision" : "9f4202ab5b8422aa90f0ed983bf7652c3af7abf0", + "version" : "0.59.0" + } + }, + { + "identity" : "swift-concurrency-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras", + "state" : { + "revision" : "479750bd98fac2e813fffcf2af0728b5b0085795", + "version" : "0.1.1" + } + }, + { + "identity" : "swift-custom-dump", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-custom-dump", + "state" : { + "revision" : "4a87bb75be70c983a9548597e8783236feb3401e", + "version" : "0.11.1" + } + }, + { + "identity" : "swift-dependencies", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-dependencies", + "state" : { + "revision" : "16fd42ae04c6e7f74a6a86395d04722c641cccee", + "version" : "0.6.0" + } + }, + { + "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", @@ -134,6 +188,96 @@ "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" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-syntax.git", + "state" : { + "branch" : "main", + "revision" : "e149b01cfd3e96240e102729697e2095c19157ef" + } + }, + { + "identity" : "swiftsoup", + "kind" : "remoteSourceControl", + "location" : "https://github.com/scinfu/SwiftSoup.git", + "state" : { + "revision" : "8b6cf29eead8841a1fa7822481cb3af4ddaadba6", + "version" : "2.6.1" + } + }, + { + "identity" : "swifttreesitter", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ChimeHQ/SwiftTreeSitter", + "state" : { + "revision" : "a9b1335d5151b62b11f07599bd07d07dc5965de3", + "version" : "0.7.2" + } + }, + { + "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" : "tree-sitter-objc", + "kind" : "remoteSourceControl", + "location" : "https://github.com/lukepistrol/tree-sitter-objc", + "state" : { + "branch" : "feature/spm", + "revision" : "1b54ef0b5efddddf393b45e173788499cc572048" + } + }, + { + "identity" : "tree-sitter-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/alex-pinkus/tree-sitter-swift", + "state" : { + "branch" : "with-generated-files", + "revision" : "eda05af7ac41adb4eb19c346883c0fa32fe3bdd8" + } + }, + { + "identity" : "usearch", + "kind" : "remoteSourceControl", + "location" : "https://github.com/unum-cloud/usearch", + "state" : { + "revision" : "33c53288b44ccb55de77776820676132a6e4c42a", + "version" : "0.23.0" + } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state" : { + "revision" : "50843cbb8551db836adec2290bb4bc6bac5c1865", + "version" : "0.9.0" + } } ], "version" : 2 diff --git a/Core/Package.swift b/Core/Package.swift index 0ed63b34..e3f59806 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -1,8 +1,62 @@ // swift-tools-version: 5.7 // The swift-tools-version declares the minimum version of Swift required to build this package. +import Foundation import PackageDescription +// MARK: - Pro + +extension [Target.Dependency] { + func pro(_ targetNames: [String]) -> [Target.Dependency] { + if isProIncluded { + // include the pro package + return self + targetNames.map { Target.Dependency.product(name: $0, package: "Pro") } + } + return self + } +} + +extension [Package.Dependency] { + var pro: [Package.Dependency] { + if isProIncluded { + // include the pro package + return self + [.package(path: "../Pro/Pro")] + } + return self + } +} + +let isProIncluded: Bool = { + func isProIncluded(file: StaticString = #file) -> Bool { + let filePath = "\(file)" + let fileURL = URL(fileURLWithPath: filePath) + let rootURL = fileURL + .deletingLastPathComponent() + .deletingLastPathComponent() + let confURL = rootURL.appendingPathComponent("PLUS") + if !FileManager.default.fileExists(atPath: confURL.path) { + return false + } + do { + if let content = String( + data: try Data(contentsOf: confURL), + encoding: .utf8 + ) { + if content.hasPrefix("YES") { + return true + } + } + return false + } catch { + return false + } + } + + return isProIncluded() +}() + +// MARK: - Package + let package = Package( name: "Core", platforms: [.macOS(.v12)], @@ -21,16 +75,13 @@ let package = Package( name: "Client", targets: [ "Client", - "XPCShared", ] ), .library( name: "HostApp", targets: [ "HostApp", - "GitHubCopilotService", "Client", - "XPCShared", "LaunchAgentManager", "UpdateChecker", ] @@ -38,9 +89,6 @@ let package = Package( ], dependencies: [ .package(path: "../Tool"), - // TODO: Update LanguageClient some day. - .package(url: "https://github.com/ChimeHQ/LanguageClient", exact: "0.3.1"), - .package(url: "https://github.com/ChimeHQ/LanguageServerProtocol", exact: "0.8.0"), .package(url: "https://github.com/apple/swift-async-algorithms", from: "0.1.0"), .package(url: "https://github.com/gonzalezreal/swift-markdown-ui", from: "2.1.0"), .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.0.0"), @@ -57,8 +105,8 @@ let package = Package( .target( name: "Client", dependencies: [ - "XPCShared", - "GitHubCopilotService", + .product(name: "XPCShared", package: "Tool"), + .product(name: "SuggestionService", package: "Tool"), .product(name: "SuggestionModel", package: "Tool"), .product(name: "Logger", package: "Tool"), .product(name: "Preferences", package: "Tool"), @@ -67,14 +115,13 @@ let package = Package( .target( name: "Service", dependencies: [ - "SuggestionService", - "GitHubCopilotService", - "XPCShared", "SuggestionWidget", "ChatService", "PromptToCodeService", "ServiceUpdateMigration", "ChatGPTChatTab", + .product(name: "XPCShared", package: "Tool"), + .product(name: "SuggestionService", package: "Tool"), .product(name: "Workspace", package: "Tool"), .product(name: "UserDefaultsObserver", package: "Tool"), .product(name: "AppMonitoring", package: "Tool"), @@ -96,9 +143,9 @@ let package = Package( dependencies: [ "Service", "Client", - "GitHubCopilotService", "SuggestionInjector", - "XPCShared", + .product(name: "XPCShared", package: "Tool"), + .product(name: "SuggestionService", package: "Tool"), .product(name: "SuggestionModel", package: "Tool"), .product(name: "Environment", package: "Tool"), .product(name: "Preferences", package: "Tool"), @@ -111,10 +158,9 @@ let package = Package( name: "HostApp", dependencies: [ "Client", - "GitHubCopilotService", - "CodeiumService", "LaunchAgentManager", "PlusFeatureFlag", + .product(name: "SuggestionService", package: "Tool"), .product(name: "Toast", package: "Tool"), .product(name: "SharedUIComponents", package: "Tool"), .product(name: "SuggestionModel", package: "Tool"), @@ -127,13 +173,6 @@ let package = Package( ]) ), - // MARK: - XPC Related - - .target( - name: "XPCShared", - dependencies: [.product(name: "SuggestionModel", package: "Tool")] - ), - // MARK: - Suggestion Service .target( @@ -144,11 +183,6 @@ let package = Package( name: "SuggestionInjectorTests", dependencies: ["SuggestionInjector"] ), - .target(name: "SuggestionService", dependencies: [ - "GitHubCopilotService", - "CodeiumService", - .product(name: "UserDefaultsObserver", package: "Tool"), - ]), // MARK: - Prompt To Code @@ -179,7 +213,6 @@ let package = Package( // context collectors "WebChatContextCollector", - "ActiveDocumentChatContextCollector", "SystemInfoChatContextCollector", .product(name: "ChatContextCollector", package: "Tool"), @@ -211,6 +244,7 @@ let package = Package( .product(name: "Logger", package: "Tool"), .product(name: "ChatTab", package: "Tool"), .product(name: "MarkdownUI", package: "swift-markdown-ui"), + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), ] ), @@ -248,7 +282,7 @@ let package = Package( .target( name: "ServiceUpdateMigration", dependencies: [ - "GitHubCopilotService", + .product(name: "SuggestionService", package: "Tool"), .product(name: "Preferences", package: "Tool"), .product(name: "Keychain", package: "Tool"), ] @@ -267,39 +301,6 @@ let package = Package( ]) ), - // MARK: - GitHub Copilot - - .target( - name: "GitHubCopilotService", - dependencies: [ - "LanguageClient", - "XPCShared", - .product(name: "SuggestionModel", package: "Tool"), - .product(name: "Logger", package: "Tool"), - .product(name: "Preferences", package: "Tool"), - .product(name: "Terminal", package: "Tool"), - .product(name: "LanguageServerProtocol", package: "LanguageServerProtocol"), - ] - ), - .testTarget( - name: "GitHubCopilotServiceTests", - dependencies: ["GitHubCopilotService"] - ), - - // MARK: - Codeium - - .target( - name: "CodeiumService", - dependencies: [ - "LanguageClient", - .product(name: "Keychain", package: "Tool"), - .product(name: "SuggestionModel", package: "Tool"), - .product(name: "AppMonitoring", package: "Tool"), - .product(name: "Preferences", package: "Tool"), - .product(name: "Terminal", package: "Tool"), - ] - ), - // MARK: - Chat Plugins .target( @@ -355,73 +356,6 @@ let package = Package( ], path: "Sources/ChatContextCollectors/SystemInfoChatContextCollector" ), - - .target( - name: "ActiveDocumentChatContextCollector", - dependencies: [ - .product(name: "ChatContextCollector", package: "Tool"), - .product(name: "OpenAIService", package: "Tool"), - .product(name: "Preferences", package: "Tool"), - .product(name: "FocusedCodeFinder", package: "Tool"), - .product(name: "AppMonitoring", package: "Tool"), - ], - path: "Sources/ChatContextCollectors/ActiveDocumentChatContextCollector" - ), - - .testTarget( - name: "ActiveDocumentChatContextCollectorTests", - dependencies: ["ActiveDocumentChatContextCollector"] - ), ] ) -// MARK: - Pro - -extension [Target.Dependency] { - func pro(_ targetNames: [String]) -> [Target.Dependency] { - if isProIncluded { - // include the pro package - return self + targetNames.map { Target.Dependency.product(name: $0, package: "Pro") } - } - return self - } -} - -extension [Package.Dependency] { - var pro: [Package.Dependency] { - if isProIncluded { - // include the pro package - return self + [.package(path: "../Pro")] - } - return self - } -} - -import Foundation - -let isProIncluded: Bool = { - func isProIncluded(file: StaticString = #file) -> Bool { - let filePath = "\(file)" - let fileURL = URL(fileURLWithPath: filePath) - let rootURL = fileURL - .deletingLastPathComponent() - .deletingLastPathComponent() - let confURL = rootURL.appendingPathComponent("PLUS") - if !FileManager.default.fileExists(atPath: confURL.path) { - return false - } - do { - let content = String( - data: try Data(contentsOf: confURL), - encoding: .utf8 - ) - print("") - return content?.hasPrefix("YES") ?? false - } catch { - return false - } - } - - return isProIncluded() -}() - diff --git a/Core/Sources/ChatContextCollectors/SystemInfoChatContextCollector/SystemInfoChatContextCollector.swift b/Core/Sources/ChatContextCollectors/SystemInfoChatContextCollector/SystemInfoChatContextCollector.swift index 19aab821..795d14e0 100644 --- a/Core/Sources/ChatContextCollectors/SystemInfoChatContextCollector/SystemInfoChatContextCollector.swift +++ b/Core/Sources/ChatContextCollectors/SystemInfoChatContextCollector/SystemInfoChatContextCollector.swift @@ -16,11 +16,14 @@ public final class SystemInfoChatContextCollector: ChatContextCollector { scopes: Set, content: String, configuration: ChatGPTConfiguration - ) -> ChatContext? { + ) -> ChatContext { return .init( systemPrompt: """ - Current Time: \(Self.dateFormatter.string(from: Date())) (You can use it to calculate time in another time zone) - """, + 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/WebChatContextCollector.swift b/Core/Sources/ChatContextCollectors/WebChatContextCollector/WebChatContextCollector.swift index bf72667f..81d1b9fc 100644 --- a/Core/Sources/ChatContextCollectors/WebChatContextCollector/WebChatContextCollector.swift +++ b/Core/Sources/ChatContextCollectors/WebChatContextCollector/WebChatContextCollector.swift @@ -12,8 +12,8 @@ public final class WebChatContextCollector: ChatContextCollector { scopes: Set, content: String, configuration: ChatGPTConfiguration - ) -> ChatContext? { - guard scopes.contains("web") || scopes.contains("w") else { return nil } + ) -> ChatContext { + guard scopes.contains("web") || scopes.contains("w") else { return .empty } let links = Self.detectLinks(from: history) + Self.detectLinks(from: content) let functions: [(any ChatGPTFunction)?] = [ SearchFunction(maxTokens: configuration.maxTokens), @@ -22,6 +22,7 @@ public final class WebChatContextCollector: ChatContextCollector { ] return .init( systemPrompt: "You prefer to answer questions with latest content on the internet.", + retrievedContent: [], functions: functions.compactMap { $0 } ) } diff --git a/Core/Sources/ChatGPTChatTab/Chat.swift b/Core/Sources/ChatGPTChatTab/Chat.swift new file mode 100644 index 00000000..72d00503 --- /dev/null +++ b/Core/Sources/ChatGPTChatTab/Chat.swift @@ -0,0 +1,311 @@ +import ChatService +import ComposableArchitecture +import Foundation +import OpenAIService +import Preferences + +public struct ChatMessage: Equatable { + public enum Role { + case user + case assistant + case function + case ignored + } + + public var id: String + public var role: Role + public var text: String + + public init(id: String, role: Role, text: String) { + self.id = id + self.role = role + self.text = text + } +} + +struct Chat: ReducerProtocol { + public typealias MessageID = String + + struct State: Equatable { + var title: String = "Chat" + @BindingState var typedMessage = "" + var history: [ChatMessage] = [] + @BindingState var isReceivingMessage = false + var chatMenu = ChatMenu.State() + } + + enum Action: Equatable, BindableAction { + case binding(BindingAction) + + case appear + case sendButtonTapped + case returnButtonTapped + case stopRespondingButtonTapped + case clearButtonTap + case deleteMessageButtonTapped(MessageID) + case resendMessageButtonTapped(MessageID) + case setAsExtraPromptButtonTapped(MessageID) + + case observeChatService + case observeHistoryChange + case observeIsReceivingMessageChange + case observeSystemPromptChange + case observeExtraSystemPromptChange + + case historyChanged + case isReceivingMessageChanged + case systemPromptChanged + case extraSystemPromptChanged + + 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) + } + + var body: some ReducerProtocol { + BindingReducer() + + Scope(state: \.chatMenu, action: /Action.chatMenu) { + ChatMenu(service: service) + } + + Reduce { state, action in + switch action { + case .appear: + return .run { send in + await send(.observeChatService) + await send(.historyChanged) + await send(.isReceivingMessageChanged) + await send(.systemPromptChanged) + await send(.extraSystemPromptChanged) + } + + case .sendButtonTapped: + guard !state.typedMessage.isEmpty else { return .none } + let message = state.typedMessage + state.typedMessage = "" + return .run { _ in + try await service.send(content: message) + } + + case .returnButtonTapped: + state.typedMessage += "\n" + return .none + + case .stopRespondingButtonTapped: + return .run { _ in + await service.stopReceivingMessage() + } + + 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 .observeChatService: + return .run { send in + await send(.observeHistoryChange) + await send(.observeIsReceivingMessageChange) + await send(.observeSystemPromptChange) + await send(.observeExtraSystemPromptChange) + } + + case .observeHistoryChange: + return .run { send in + let stream = AsyncStream { continuation in + let cancellable = service.$chatHistory.sink { _ in + continuation.yield() + } + continuation.onTermination = { _ in + cancellable.cancel() + } + } + for await _ in stream { + await send(.historyChanged) + } + }.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 .historyChanged: + state.history = service.chatHistory.map { message in + .init( + id: message.id, + role: { + switch message.role { + case .system: return .ignored + case .user: return .user + case .assistant: + if let text = message.summary ?? message.content, + !text.isEmpty + { + return .assistant + } + return .ignored + case .function: return .function + } + }(), + text: message.summary ?? message.content ?? "" + ) + } + + 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 + return .none + + case .systemPromptChanged: + state.chatMenu.systemPrompt = service.systemPrompt + return .none + + case .extraSystemPromptChanged: + state.chatMenu.extraSystemPrompt = service.extraSystemPrompt + return .none + + case .binding: + return .none + + case .chatMenu: + return .none + } + } + } +} + +struct ChatMenu: ReducerProtocol { + struct State: Equatable { + var systemPrompt: String = "" + var extraSystemPrompt: String = "" + var temperatureOverride: Double? = nil + var chatModelIdOverride: String? = nil + } + + enum Action: Equatable { + case appear + case resetPromptButtonTapped + case temperatureOverrideSelected(Double?) + case chatModelIdOverrideSelected(String?) + case customCommandButtonTapped(CustomCommand) + } + + let service: ChatService + + var body: some ReducerProtocol { + Reduce { state, action in + switch action { + case .appear: + 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) + } + } + } + } +} + diff --git a/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift b/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift index ab9a2aa7..bff64f4e 100644 --- a/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift +++ b/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift @@ -1,17 +1,20 @@ import AppKit +import ComposableArchitecture import SharedUIComponents import SwiftUI struct ChatTabItemView: View { - @ObservedObject var chat: ChatProvider + let chat: StoreOf var body: some View { - Text(chat.title) + WithViewStore(chat, observe: \.title) { viewStore in + Text(viewStore.state) + } } } struct ChatContextMenu: View { - @ObservedObject var chat: ChatProvider + let store: StoreOf @AppStorage(\.customCommands) var customCommands @AppStorage(\.chatModels) var chatModels @AppStorage(\.defaultChatFeatureChatModelId) var defaultChatModelId @@ -19,6 +22,7 @@ struct ChatContextMenu: View { var body: some View { currentSystemPrompt + .onAppear { store.send(.appear) } currentExtraSystemPrompt resetPrompt @@ -35,77 +39,81 @@ struct ChatContextMenu: View { @ViewBuilder var currentSystemPrompt: some View { Text("System Prompt:") - Text({ - var text = chat.systemPrompt - if text.isEmpty { text = "N/A" } - if text.count > 30 { text = String(text.prefix(30)) + "..." } - return text - }() as String) + WithViewStore(store, observe: \.systemPrompt) { viewStore in + Text({ + var text = viewStore.state + if text.isEmpty { text = "N/A" } + if text.count > 30 { text = String(text.prefix(30)) + "..." } + return text + }() as String) + } } @ViewBuilder var currentExtraSystemPrompt: some View { Text("Extra Prompt:") - Text({ - var text = chat.extraSystemPrompt - if text.isEmpty { text = "N/A" } - if text.count > 30 { text = String(text.prefix(30)) + "..." } - return text - }() as String) + WithViewStore(store, observe: \.extraSystemPrompt) { viewStore in + Text({ + var text = viewStore.state + if text.isEmpty { text = "N/A" } + if text.count > 30 { text = String(text.prefix(30)) + "..." } + return text + }() as String) + } } var resetPrompt: some View { Button("Reset System Prompt") { - chat.resetPrompt() + store.send(.resetPromptButtonTapped) } } @ViewBuilder var chatModel: some View { Menu("Chat Model") { - Button(action: { - chat.chatModelId = nil - }) { - HStack { - if let defaultModel = chatModels.first(where: { $0.id == defaultChatModelId }) { - Text("Default (\(defaultModel.name))") - if chat.chatModelId == nil { - Image(systemName: "checkmark") - } - } else { - Text("No Model Available") - } - } - } - - if let id = chat.chatModelId, - !chatModels.map(\.id).contains(id) - { + WithViewStore(store, observe: \.chatModelIdOverride) { viewStore in Button(action: { - chat.chatModelId = nil - chat.objectWillChange.send() + viewStore.send(.chatModelIdOverrideSelected(nil)) }) { HStack { - Text("Default (Selected Model Not Found)") - Image(systemName: "checkmark") + if let defaultModel = chatModels + .first(where: { $0.id == defaultChatModelId }) + { + Text("Default (\(defaultModel.name))") + if viewStore.state == nil { + Image(systemName: "checkmark") + } + } else { + Text("No Model Available") + } } } - } - - Divider() - ForEach(chatModels, id: \.id) { model in - Button(action: { - chat.chatModelId = model.id - chat.objectWillChange.send() - }) { - HStack { - Text(model.name) - if model.id == chat.chatModelId { + if let id = viewStore.state, !chatModels.map(\.id).contains(id) { + Button(action: { + viewStore.send(.chatModelIdOverrideSelected(nil)) + }) { + HStack { + Text("Default (Selected Model Not Found)") Image(systemName: "checkmark") } } } + + Divider() + + ForEach(chatModels, id: \.id) { model in + Button(action: { + viewStore.send(.chatModelIdOverrideSelected(model.id)) + }) { + HStack { + Text(model.name) + if model.id == viewStore.state { + Image(systemName: "checkmark") + } + } + } + } } } } @@ -113,32 +121,34 @@ struct ChatContextMenu: View { @ViewBuilder var temperature: some View { Menu("Temperature") { - Button(action: { - chat.temperature = nil - }) { - HStack { - Text( - "Default (\(defaultTemperature.formatted(.number.precision(.fractionLength(1)))))" - ) - if chat.temperature == nil { - Image(systemName: "checkmark") - } - } - } - - Divider() - - ForEach(Array(stride(from: 0.0, through: 2.0, by: 0.1)), id: \.self) { value in + WithViewStore(store, observe: \.temperatureOverride) { viewStore in Button(action: { - chat.temperature = value + viewStore.send(.temperatureOverrideSelected(nil)) }) { HStack { - Text("\(value.formatted(.number.precision(.fractionLength(1))))") - if value == chat.temperature { + Text( + "Default (\(defaultTemperature.formatted(.number.precision(.fractionLength(1)))))" + ) + if viewStore.state == nil { Image(systemName: "checkmark") } } } + + Divider() + + ForEach(Array(stride(from: 0.0, through: 2.0, by: 0.1)), id: \.self) { value in + Button(action: { + viewStore.send(.temperatureOverrideSelected(value)) + }) { + HStack { + Text("\(value.formatted(.number.precision(.fractionLength(1))))") + if value == viewStore.state { + Image(systemName: "checkmark") + } + } + } + } } } } @@ -156,7 +166,7 @@ struct ChatContextMenu: View { id: \.name ) { command in Button(action: { - chat.triggerCustomCommand(command) + store.send(.customCommandButtonTapped(command)) }) { Text(command.name) } diff --git a/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift b/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift index 17532b24..3841089f 100644 --- a/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift +++ b/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift @@ -12,7 +12,8 @@ public class ChatGPTChatTab: ChatTab { public static var name: String { "Chat" } public let service: ChatService - public let provider: ChatProvider + let chat: StoreOf + let viewStore: ViewStoreOf private var cancellable = Set() struct RestorableState: Codable { @@ -28,7 +29,7 @@ public class ChatGPTChatTab: ChatTab { var afterBuild: (ChatGPTChatTab) async -> Void = { _ in } func build(store: StoreOf) async -> (any ChatTab)? { - let tab = ChatGPTChatTab(store: store) + let tab = await ChatGPTChatTab(store: store) if let customCommand { try? await tab.service.handleCustomCommand(customCommand) } @@ -38,15 +39,15 @@ public class ChatGPTChatTab: ChatTab { } public func buildView() -> any View { - ChatPanel(chat: provider) + ChatPanel(chat: chat) } public func buildTabItem() -> any View { - ChatTabItemView(chat: provider) + ChatTabItemView(chat: chat) } public func buildMenu() -> any View { - ChatContextMenu(chat: provider) + ChatContextMenu(store: chat.scope(state: \.chatMenu, action: Chat.Action.chatMenu)) } public func restorableState() async -> Data { @@ -87,9 +88,11 @@ public class ChatGPTChatTab: ChatTab { return [Builder(title: "New Chat", customCommand: nil)] + customCommands } + @MainActor public init(service: ChatService = .init(), store: StoreOf) { self.service = service - provider = .init(service: service) + chat = .init(initialState: .init(), reducer: Chat(service: service)) + viewStore = .init(chat) super.init(store: store) } @@ -108,15 +111,13 @@ public class ChatGPTChatTab: ChatTab { } }.store(in: &cancellable) - provider.$history.sink { [weak self] _ in + viewStore.publisher.map(\.title).removeDuplicates().sink { [weak self] title in Task { @MainActor [weak self] in - if let title = self?.provider.title { - self?.chatTabViewStore.send(.updateTitle(title)) - } + self?.chatTabViewStore.send(.updateTitle(title)) } }.store(in: &cancellable) - provider.objectWillChange.debounce(for: .seconds(1), scheduler: DispatchQueue.main) + viewStore.publisher.removeDuplicates() .sink { [weak self] _ in Task { @MainActor [weak self] in self?.chatTabViewStore.send(.tabContentUpdated) @@ -124,90 +125,3 @@ public class ChatGPTChatTab: ChatTab { }.store(in: &cancellable) } } - -extension ChatProvider { - convenience init(service: ChatService) { - self.init( - configuration: service.configuration, - pluginIdentifiers: service.allPluginCommands - ) - - let cancellable = service.objectWillChange.sink { [weak self] in - guard let self else { return } - Task { @MainActor in - self.history = (await service.memory.history).map { message in - .init( - id: message.id, - role: { - switch message.role { - case .system: return .ignored - case .user: return .user - case .assistant: - if let text = message.summary ?? message.content, !text.isEmpty { - return .assistant - } - return .ignored - case .function: return .function - } - }(), - text: message.summary ?? message.content ?? "" - ) - } - self.isReceivingMessage = service.isReceivingMessage - self.systemPrompt = service.systemPrompt - self.extraSystemPrompt = service.extraSystemPrompt - } - } - - service.objectWillChange.send() - - onMessageSend = { [cancellable] message in - _ = cancellable - Task { - try await service.send(content: message) - } - } - onStop = { - Task { - await service.stopReceivingMessage() - } - } - - onClear = { - Task { - await service.clearHistory() - } - } - - onDeleteMessage = { id in - Task { - await service.deleteMessage(id: id) - } - } - - onResendMessage = { id in - Task { - try await service.resendMessage(id: id) - } - } - - onResetPrompt = { - Task { - await service.resetPrompt() - } - } - - onRunCustomCommand = { command in - Task { - try await service.handleCustomCommand(command) - } - } - - onSetAsExtraPrompt = { id in - Task { - await service.setMessageAsExtraPrompt(id: id) - } - } - } -} - diff --git a/Core/Sources/ChatGPTChatTab/ChatPanel.swift b/Core/Sources/ChatGPTChatTab/ChatPanel.swift index ad537aa4..c7a9d482 100644 --- a/Core/Sources/ChatGPTChatTab/ChatPanel.swift +++ b/Core/Sources/ChatGPTChatTab/ChatPanel.swift @@ -1,88 +1,208 @@ import AppKit -import OpenAIService +import ComposableArchitecture import MarkdownUI +import OpenAIService import SharedUIComponents import SwiftUI private let r: Double = 8 public struct ChatPanel: View { - @ObservedObject var chat: ChatProvider + let chat: StoreOf @Namespace var inputAreaNamespace - @State var typedMessage = "" - - public init(chat: ChatProvider, typedMessage: String = "") { - self.chat = chat - self.typedMessage = typedMessage - } public var body: some View { VStack(spacing: 0) { - ChatPanelMessages( - chat: chat - ) + ChatPanelMessages(chat: chat) Divider() - ChatPanelInputArea( - chat: chat, - typedMessage: $typedMessage - ) + ChatPanelInputArea(chat: chat) } .background(.regularMaterial) + .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 { - @ObservedObject var chat: ChatProvider + let chat: StoreOf + @State var pinnedToBottom = true + @Namespace var bottomID + @Namespace var scrollSpace + @State var scrollOffset: Double = 0 + @State var listHeight: Double = 0 + @State var isInitialLoad = true var body: some View { - List { - Group { - Spacer() - - if chat.isReceivingMessage { - StopRespondingButton(chat: chat) - .padding(.vertical, 4) - .listRowInsets(EdgeInsets(top: 0, leading: -8, bottom: 0, trailing: -8)) + ScrollViewReader { proxy in + GeometryReader { listGeo in + List { + Group { + Spacer(minLength: 12) + + Instruction() + + ChatHistory(chat: chat) + .listItemTint(.clear) + + WithViewStore(chat, observe: \.isReceivingMessage) { viewStore in + if viewStore.state { + Spacer(minLength: 12) + } + } + + Spacer(minLength: 12) + .onAppear { + withAnimation { + proxy.scrollTo(bottomID, anchor: .bottom) + } + } + .id(bottomID) + .background(GeometryReader { geo in + let offset = geo.frame(in: .named(scrollSpace)).minY + Color.clear + .preference( + key: ScrollViewOffsetPreferenceKey.self, + value: offset + ) + }) + .preference( + key: ListHeightPreferenceKey.self, + value: listGeo.size.height + ) + } + .modify { view in + if #available(macOS 13.0, *) { + view.listRowSeparator(.hidden).listSectionSeparator(.hidden) + } else { + view + } + } } - - ForEach(chat.history.reversed(), id: \.id) { message in - let text = message.text - - switch message.role { - case .user: - UserMessage(id: message.id, text: text, chat: chat) - .listRowInsets(EdgeInsets(top: 0, leading: -8, bottom: 0, trailing: -8)) - .padding(.vertical, 4) - case .assistant: - BotMessage(id: message.id, text: text, chat: chat) - .listRowInsets(EdgeInsets(top: 0, leading: -8, bottom: 0, trailing: -8)) - .padding(.vertical, 4) - case .function: - FunctionMessage(id: message.id, text: text) - case .ignored: - EmptyView() + .listStyle(.plain) + .coordinateSpace(name: scrollSpace) + .onPreferenceChange(ListHeightPreferenceKey.self) { value in + listHeight = value + updatePinningState() + } + .onPreferenceChange(ScrollViewOffsetPreferenceKey.self) { value in + scrollOffset = value + updatePinningState() + } + .overlay(alignment: .bottom) { + WithViewStore(chat, observe: \.isReceivingMessage) { viewStore in + StopRespondingButton(chat: chat) + .padding(.bottom, 8) + .opacity(viewStore.state ? 1 : 0) + .disabled(!viewStore.state) + .transformEffect(.init(translationX: 0, y: viewStore.state ? 0 : 20)) + .animation(.easeInOut(duration: 0.2), value: viewStore.state) } } - .listItemTint(.clear) + .overlay(alignment: .bottomTrailing) { + WithViewStore(chat, observe: \.history.last) { viewStore in + Button(action: { + withAnimation(.easeInOut(duration: 0.1)) { + proxy.scrollTo(bottomID, anchor: .bottom) + } + }) { + Image(systemName: "arrow.down") + .padding(4) + .background { + Circle() + .fill(.thickMaterial) + .shadow(color: .black.opacity(0.2), radius: 2) + } + .overlay { + Circle().stroke(Color(nsColor: .separatorColor), lineWidth: 1) + } + .foregroundStyle(.secondary) + .padding(4) + } + .opacity(pinnedToBottom ? 0 : 1) + .buttonStyle(.plain) + .onChange(of: viewStore.state) { _ in + if pinnedToBottom || isInitialLoad { + if isInitialLoad { + isInitialLoad = false + } + withAnimation { + proxy.scrollTo(bottomID, anchor: .bottom) + } + } + } + } + } + } + } + } - Instruction() + func updatePinningState() { + if scrollOffset > listHeight + 24 + 100 || scrollOffset <= 0 { + pinnedToBottom = false + } else { + pinnedToBottom = true + } + } +} + +struct ChatHistory: View { + let chat: StoreOf - Spacer() + var body: some View { + WithViewStore(chat, observe: \.history) { viewStore in + ForEach(viewStore.state, id: \.id) { message in + let text = message.text + + switch message.role { + case .user: + UserMessage(id: message.id, text: text, chat: chat) + .listRowInsets(EdgeInsets( + top: 0, + leading: -8, + bottom: 0, + trailing: -8 + )) + .padding(.vertical, 4) + case .assistant: + BotMessage(id: message.id, text: text, chat: chat) + .listRowInsets(EdgeInsets( + top: 0, + leading: -8, + bottom: 0, + trailing: -8 + )) + .padding(.vertical, 4) + case .function: + FunctionMessage(id: message.id, text: text) + case .ignored: + EmptyView() + } } - .scaleEffect(x: -1, y: 1, anchor: .center) } - .id("\(chat.history.count), \(chat.isReceivingMessage)") - .listStyle(.plain) - .scaleEffect(x: 1, y: -1, anchor: .center) } } private struct StopRespondingButton: View { - let chat: ChatProvider + let chat: StoreOf var body: some View { Button(action: { - chat.stop() + chat.send(.stopRespondingButtonTapped) }) { HStack(spacing: 4) { Image(systemName: "stop.fill") @@ -99,7 +219,6 @@ private struct StopRespondingButton: View { } } .buttonStyle(.borderless) - .scaleEffect(x: -1, y: -1, anchor: .center) .frame(maxWidth: .infinity, alignment: .center) } } @@ -112,13 +231,17 @@ private struct Instruction: View { Group { Markdown( """ - Hello, I am your AI programming assistant. I can identify issues, explain and even improve code. + You can use plugins to perform various tasks. - \( - useCodeScopeByDefaultInChatContext - ? "Scope **`@code`** is enabled by default." - : "Scope **`@file`** is enabled by default." - ) + | Plugin Name | Description | + | --- | --- | + | `/run` | Runs a command under the project root | + | `/math` | Solves a math problem in natural language | + | `/search` | Searches on Bing and summarizes the results | + | `/shortcut(name)` | Runs a shortcut from the Shortcuts.app, with the previous message as input | + | `/shortcutInput(name)` | Runs a shortcut and uses its result as a new message | + + To use plugins, you can prefix a message with `/pluginName`. """ ) .modifier(InstructionModifier()) @@ -143,17 +266,13 @@ private struct Instruction: View { Markdown( """ - You can use plugins to perform various tasks. - - | Plugin Name | Description | - | --- | --- | - | `/run` | Runs a command under the project root | - | `/math` | Solves a math problem in natural language | - | `/search` | Searches on Bing and summarizes the results | - | `/shortcut(name)` | Runs a shortcut from the Shortcuts.app, with the previous message as input | - | `/shortcutInput(name)` | Runs a shortcut and uses its result as a new message | + Hello, I am your AI programming assistant. I can identify issues, explain and even improve code. - To use plugins, you can prefix a message with `/pluginName`. + \( + useCodeScopeByDefaultInChatContext + ? "Scope **`@code`** is enabled by default." + : "Scope **`@file`** is enabled by default." + ) """ ) .modifier(InstructionModifier()) @@ -174,7 +293,6 @@ private struct Instruction: View { RoundedRectangle(cornerRadius: 8) .stroke(Color(nsColor: .separatorColor), lineWidth: 1) } - .scaleEffect(x: -1, y: -1, anchor: .center) } } } @@ -182,7 +300,7 @@ private struct Instruction: View { private struct UserMessage: View { let id: String let text: String - let chat: ChatProvider + let chat: StoreOf @Environment(\.colorScheme) var colorScheme @AppStorage(\.chatFontSize) var chatFontSize @AppStorage(\.chatCodeFontSize) var chatCodeFontSize @@ -209,9 +327,8 @@ private struct UserMessage: View { } .padding(.leading) .padding(.trailing, 8) - .scaleEffect(x: -1, y: -1, anchor: .center) .shadow(color: .black.opacity(0.1), radius: 2) - .frame(maxWidth: .infinity, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .trailing) .contextMenu { Button("Copy") { NSPasteboard.general.clearContents() @@ -219,17 +336,17 @@ private struct UserMessage: View { } Button("Send Again") { - chat.resendMessage(id: id) + chat.send(.resendMessageButtonTapped(id)) } Button("Set as Extra System Prompt") { - chat.setAsExtraPrompt(id: id) + chat.send(.setAsExtraPromptButtonTapped(id)) } Divider() Button("Delete") { - chat.deleteMessage(id: id) + chat.send(.deleteMessageButtonTapped(id)) } } } @@ -238,19 +355,13 @@ private struct UserMessage: View { private struct BotMessage: View { let id: String let text: String - let chat: ChatProvider + let chat: StoreOf @Environment(\.colorScheme) var colorScheme @AppStorage(\.chatFontSize) var chatFontSize @AppStorage(\.chatCodeFontSize) var chatCodeFontSize var body: some View { HStack(alignment: .bottom, spacing: 2) { - CopyButton { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(text, forType: .string) - } - .scaleEffect(x: -1, y: -1, anchor: .center) - Markdown(text) .textSelection(.enabled) .markdownTheme(.custom(fontSize: chatFontSize)) @@ -271,7 +382,6 @@ private struct BotMessage: View { .stroke(Color(nsColor: .separatorColor), lineWidth: 1) } .padding(.leading, 8) - .scaleEffect(x: -1, y: -1, anchor: .center) .shadow(color: .black.opacity(0.1), radius: 2) .contextMenu { Button("Copy") { @@ -280,17 +390,22 @@ private struct BotMessage: View { } Button("Set as Extra System Prompt") { - chat.setAsExtraPrompt(id: id) + chat.send(.setAsExtraPromptButtonTapped(id)) } Divider() Button("Delete") { - chat.deleteMessage(id: id) + chat.send(.deleteMessageButtonTapped(id)) } } + + CopyButton { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + } } - .frame(maxWidth: .infinity, alignment: .trailing) + .frame(maxWidth: .infinity, alignment: .leading) .padding(.trailing, 2) } } @@ -304,15 +419,13 @@ struct FunctionMessage: View { Markdown(text) .textSelection(.enabled) .markdownTheme(.functionCall(fontSize: chatFontSize)) - .scaleEffect(x: -1, y: -1, anchor: .center) .padding(.vertical, 2) .padding(.trailing, 2) } } struct ChatPanelInputArea: View { - @ObservedObject var chat: ChatProvider - @Binding var typedMessage: String + let chat: StoreOf @FocusState var isInputAreaFocused: Bool var body: some View { @@ -327,9 +440,10 @@ struct ChatPanelInputArea: View { .background(.ultraThickMaterial) } + @MainActor var clearButton: some View { Button(action: { - chat.clear() + chat.send(.clearButtonTap) }) { Group { if #available(macOS 13.0, *) { @@ -343,46 +457,53 @@ struct ChatPanelInputArea: View { Circle().fill(Color(nsColor: .controlBackgroundColor)) } .overlay { - Circle() - .stroke(Color(nsColor: .controlColor), lineWidth: 1) + Circle().stroke(Color(nsColor: .controlColor), lineWidth: 1) } } .buttonStyle(.plain) } + @MainActor var textEditor: some View { HStack(spacing: 0) { - ZStack(alignment: .center) { - // a hack to support dynamic height of TextEditor - Text(typedMessage.isEmpty ? "Hi" : typedMessage).opacity(0) - .font(.system(size: 14)) - .frame(maxWidth: .infinity, maxHeight: 400) + WithViewStore(chat, removeDuplicates: { $0.typedMessage == $1.typedMessage }) { + viewStore in + ZStack(alignment: .center) { + // a hack to support dynamic height of TextEditor + Text( + viewStore.state.typedMessage.isEmpty ? "Hi" : viewStore.state.typedMessage + ).opacity(0) + .font(.system(size: 14)) + .frame(maxWidth: .infinity, maxHeight: 400) + .padding(.top, 1) + .padding(.bottom, 2) + .padding(.horizontal, 4) + + CustomTextEditor( + text: viewStore.$typedMessage, + font: .systemFont(ofSize: 14), + onSubmit: { viewStore.send(.sendButtonTapped) }, + completions: chatAutoCompletion + ) .padding(.top, 1) - .padding(.bottom, 2) - .padding(.horizontal, 4) - - CustomTextEditor( - text: $typedMessage, - font: .systemFont(ofSize: 14), - onSubmit: { submitText() }, - completions: chatAutoCompletion - ) - .padding(.top, 1) - .padding(.bottom, -1) + .padding(.bottom, -1) + } + .focused($isInputAreaFocused) + .padding(8) + .fixedSize(horizontal: false, vertical: true) } - .focused($isInputAreaFocused) - .padding(8) - .fixedSize(horizontal: false, vertical: true) - Button(action: { - submitText() - }) { - Image(systemName: "paperplane.fill") - .padding(8) + WithViewStore(chat, observe: \.isReceivingMessage) { viewStore in + Button(action: { + viewStore.send(.sendButtonTapped) + }) { + Image(systemName: "paperplane.fill") + .padding(8) + } + .buttonStyle(.plain) + .disabled(viewStore.state) + .keyboardShortcut(KeyEquivalent.return, modifiers: []) } - .buttonStyle(.plain) - .disabled(chat.isReceivingMessage) - .keyboardShortcut(KeyEquivalent.return, modifiers: []) } .frame(maxWidth: .infinity) .background { @@ -395,7 +516,7 @@ struct ChatPanelInputArea: View { } .background { Button(action: { - typedMessage += "\n" + chat.send(.returnButtonTapped) }) { EmptyView() } @@ -410,19 +531,13 @@ struct ChatPanelInputArea: View { } } - func submitText() { - if typedMessage.isEmpty { return } - chat.send(typedMessage) - typedMessage = "" - } - func chatAutoCompletion(text: String, proposed: [String], range: NSRange) -> [String] { guard text.count == 1 else { return [] } - let plugins = chat.pluginIdentifiers.map { "/\($0)" } + let plugins = [String]() // chat.pluginIdentifiers.map { "/\($0)" } let availableFeatures = plugins + [ "/exit", "@code", - "@file", + "@project", "@web", ] @@ -582,9 +697,8 @@ struct ChatPanel_Preview: PreviewProvider { static var previews: some View { ChatPanel(chat: .init( - configuration: UserPreferenceChatGPTConfiguration().overriding(.init()), - history: ChatPanel_Preview.history, - isReceivingMessage: true + initialState: .init(history: ChatPanel_Preview.history, isReceivingMessage: true), + reducer: Chat(service: .init()) )) .frame(width: 450, height: 1200) .colorScheme(.dark) @@ -594,9 +708,8 @@ struct ChatPanel_Preview: PreviewProvider { struct ChatPanel_EmptyChat_Preview: PreviewProvider { static var previews: some View { ChatPanel(chat: .init( - configuration: UserPreferenceChatGPTConfiguration().overriding(.init()), - history: [], - isReceivingMessage: false + initialState: .init(history: [], isReceivingMessage: false), + reducer: Chat(service: .init()) )) .padding() .frame(width: 450, height: 600) @@ -627,9 +740,8 @@ struct ChatCodeSyntaxHighlighter: CodeSyntaxHighlighter { struct ChatPanel_InputText_Preview: PreviewProvider { static var previews: some View { ChatPanel(chat: .init( - configuration: UserPreferenceChatGPTConfiguration().overriding(.init()), - history: ChatPanel_Preview.history, - isReceivingMessage: false + initialState: .init(history: ChatPanel_Preview.history, isReceivingMessage: false), + reducer: Chat(service: .init()) )) .padding() .frame(width: 450, height: 600) @@ -641,11 +753,14 @@ struct ChatPanel_InputMultilineText_Preview: PreviewProvider { static var previews: some View { ChatPanel( chat: .init( - configuration: UserPreferenceChatGPTConfiguration().overriding(.init()), - history: ChatPanel_Preview.history, - isReceivingMessage: false - ), - typedMessage: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce turpis dolor, malesuada quis fringilla sit amet, placerat at nunc. Suspendisse orci tortor, tempor nec blandit a, malesuada vel tellus. Nunc sed leo ligula. Ut at ligula eget turpis pharetra tristique. Integer luctus leo non elit rhoncus fermentum." + initialState: .init( + typedMessage: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce turpis dolor, malesuada quis fringilla sit amet, placerat at nunc. Suspendisse orci tortor, tempor nec blandit a, malesuada vel tellus. Nunc sed leo ligula. Ut at ligula eget turpis pharetra tristique. Integer luctus leo non elit rhoncus fermentum.", + + history: ChatPanel_Preview.history, + isReceivingMessage: false + ), + reducer: Chat(service: .init()) + ) ) .padding() .frame(width: 450, height: 600) @@ -656,9 +771,8 @@ struct ChatPanel_InputMultilineText_Preview: PreviewProvider { struct ChatPanel_Light_Preview: PreviewProvider { static var previews: some View { ChatPanel(chat: .init( - configuration: UserPreferenceChatGPTConfiguration().overriding(.init()), - history: ChatPanel_Preview.history, - isReceivingMessage: true + initialState: .init(history: ChatPanel_Preview.history, isReceivingMessage: true), + reducer: Chat(service: .init()) )) .padding() .frame(width: 450, height: 600) diff --git a/Core/Sources/ChatGPTChatTab/ChatProvider.swift b/Core/Sources/ChatGPTChatTab/ChatProvider.swift deleted file mode 100644 index cce47476..00000000 --- a/Core/Sources/ChatGPTChatTab/ChatProvider.swift +++ /dev/null @@ -1,119 +0,0 @@ -import Foundation -import OpenAIService -import Preferences -import SwiftUI - -public final class ChatProvider: ObservableObject { - public typealias MessageID = String - public let id = UUID() - @Published public var history: [ChatMessage] = [] - @Published public var isReceivingMessage = false - public var temperature: Double? { - get { - configuration.overriding.temperature - } - set { - configuration.overriding.temperature = newValue - objectWillChange.send() - } - } - public var chatModelId: String? { - get { - configuration.overriding.modelId - } - set { - configuration.overriding.modelId = newValue - objectWillChange.send() - } - } - private let configuration: OverridingChatGPTConfiguration - public var pluginIdentifiers: [String] = [] - public var systemPrompt = "" - - public var title: String { - let defaultTitle = "Chat" - guard let lastMessageText = history - .filter({ $0.role == .assistant || $0.role == .user }) - .last? - .text else { return defaultTitle } - if lastMessageText.isEmpty { return defaultTitle } - let trimmed = lastMessageText - .trimmingCharacters(in: .punctuationCharacters) - .trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.starts(with: "```") { - return "Code Block" - } else { - return trimmed - } - } - - public var extraSystemPrompt = "" - public var onMessageSend: (String) -> Void - public var onStop: () -> Void - public var onClear: () -> Void - public var onDeleteMessage: (MessageID) -> Void - public var onResendMessage: (MessageID) -> Void - public var onResetPrompt: () -> Void - public var onRunCustomCommand: (CustomCommand) -> Void = { _ in } - public var onSetAsExtraPrompt: (MessageID) -> Void - - public init( - configuration: OverridingChatGPTConfiguration, - history: [ChatMessage] = [], - isReceivingMessage: Bool = false, - pluginIdentifiers: [String] = [], - onMessageSend: @escaping (String) -> Void = { _ in }, - onStop: @escaping () -> Void = {}, - onClear: @escaping () -> Void = {}, - onDeleteMessage: @escaping (MessageID) -> Void = { _ in }, - onResendMessage: @escaping (MessageID) -> Void = { _ in }, - onResetPrompt: @escaping () -> Void = {}, - onRunCustomCommand: @escaping (CustomCommand) -> Void = { _ in }, - onSetAsExtraPrompt: @escaping (MessageID) -> Void = { _ in } - ) { - self.configuration = configuration - self.history = history - self.isReceivingMessage = isReceivingMessage - self.pluginIdentifiers = pluginIdentifiers - self.onMessageSend = onMessageSend - self.onStop = onStop - self.onClear = onClear - self.onDeleteMessage = onDeleteMessage - self.onResendMessage = onResendMessage - self.onResetPrompt = onResetPrompt - self.onRunCustomCommand = onRunCustomCommand - self.onSetAsExtraPrompt = onSetAsExtraPrompt - } - - public func send(_ message: String) { onMessageSend(message) } - public func stop() { onStop() } - public func clear() { onClear() } - public func deleteMessage(id: MessageID) { onDeleteMessage(id) } - public func resendMessage(id: MessageID) { onResendMessage(id) } - public func resetPrompt() { onResetPrompt() } - public func triggerCustomCommand(_ command: CustomCommand) { - onRunCustomCommand(command) - } - - public func setAsExtraPrompt(id: MessageID) { onSetAsExtraPrompt(id) } -} - -public struct ChatMessage: Equatable { - public enum Role { - case user - case assistant - case function - case ignored - } - - public var id: String - public var role: Role - public var text: String - - public init(id: String, role: Role, text: String) { - self.id = id - self.role = role - self.text = text - } -} - diff --git a/Core/Sources/ChatGPTChatTab/Styles.swift b/Core/Sources/ChatGPTChatTab/Styles.swift index 330cc92f..2fa6c6be 100644 --- a/Core/Sources/ChatGPTChatTab/Styles.swift +++ b/Core/Sources/ChatGPTChatTab/Styles.swift @@ -33,6 +33,40 @@ extension NSAppearance { } } +extension View { + func codeBlockLabelStyle() -> some View { + self + .relativeLineSpacing(.em(0.225)) + .markdownTextStyle { + FontFamilyVariant(.monospaced) + FontSize(.em(0.85)) + } + .padding(16) + .padding(.top, 14) + } + + func codeBlockStyle(_ configuration: CodeBlockConfiguration) -> some View { + self + .background(Color(nsColor: .textBackgroundColor).opacity(0.7)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay(alignment: .top) { + HStack(alignment: .center) { + Text(configuration.language ?? "code") + .foregroundStyle(.tertiary) + .font(.callout) + .padding(.leading, 8) + .lineLimit(1) + Spacer() + CopyButton { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(configuration.content, forType: .string) + } + } + } + .markdownMargin(top: 4, bottom: 16) + } +} + extension MarkdownUI.Theme { static func custom(fontSize: Double) -> MarkdownUI.Theme { .gitHub.text { @@ -41,31 +75,20 @@ extension MarkdownUI.Theme { FontSize(fontSize) } .codeBlock { configuration in - configuration.label - .relativeLineSpacing(.em(0.225)) - .markdownTextStyle { - FontFamilyVariant(.monospaced) - FontSize(.em(0.85)) - } - .padding(16) - .padding(.top, 14) - .background(Color(nsColor: .textBackgroundColor).opacity(0.7)) - .clipShape(RoundedRectangle(cornerRadius: 6)) - .overlay(alignment: .top) { - HStack(alignment: .center) { - Text(configuration.language ?? "code") - .foregroundStyle(.tertiary) - .font(.callout) - .padding(.leading, 8) - .lineLimit(1) - Spacer() - CopyButton { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(configuration.content, forType: .string) - } - } + let wrapCode = UserDefaults.shared.value(for: \.wrapCodeInChatCodeBlock) + + if wrapCode { + configuration.label + .codeBlockLabelStyle() + .codeBlockStyle(configuration) + } else { + ScrollView(.horizontal) { + configuration.label + .codeBlockLabelStyle() } - .markdownMargin(top: 4, bottom: 16) + .workaroundForVerticalScrollingBugInMacOS() + .codeBlockStyle(configuration) + } } } @@ -98,3 +121,38 @@ extension MarkdownUI.Theme { } } +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 } + } +} + diff --git a/Core/Sources/ChatService/AllContextCollector.swift b/Core/Sources/ChatService/AllContextCollector.swift index ec365e9d..c13aa612 100644 --- a/Core/Sources/ChatService/AllContextCollector.swift +++ b/Core/Sources/ChatService/AllContextCollector.swift @@ -6,7 +6,6 @@ import WebChatContextCollector import ProChatContextCollectors let allContextCollectors: [any ChatContextCollector] = [ SystemInfoChatContextCollector(), - ActiveDocumentChatContextCollector(), WebChatContextCollector(), ProChatContextCollectors(), ] diff --git a/Core/Sources/ChatService/ChatService.swift b/Core/Sources/ChatService/ChatService.swift index 57b6bbec..2be2c2f8 100644 --- a/Core/Sources/ChatService/ChatService.swift +++ b/Core/Sources/ChatService/ChatService.swift @@ -10,6 +10,7 @@ public final class ChatService: ObservableObject { public let configuration: OverridingChatGPTConfiguration public let chatGPTService: any ChatGPTServiceType 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) @@ -54,7 +55,9 @@ public final class ChatService: ObservableObject { memory.chatService = self memory.observeHistoryChange { [weak self] in - self?.objectWillChange.send() + Task { [weak self] in + self?.chatHistory = await memory.history + } } } diff --git a/Core/Sources/ChatService/DynamicContextController.swift b/Core/Sources/ChatService/DynamicContextController.swift index 5c1cc81a..17c876b8 100644 --- a/Core/Sources/ChatService/DynamicContextController.swift +++ b/Core/Sources/ChatService/DynamicContextController.swift @@ -46,21 +46,42 @@ final class DynamicContextController { functionProvider.removeAll() let language = UserDefaults.shared.value(for: \.chatGPTLanguage) let oldMessages = await memory.history - let contexts = contextCollectors.compactMap { - $0.generateContext( - history: oldMessages, - scopes: scopes, - content: content, - configuration: configuration - ) + let contexts = await withTaskGroup( + of: ChatContext.self + ) { [scopes, content, configuration] group in + for collector in contextCollectors { + group.addTask { + await collector.generateContext( + history: oldMessages, + scopes: scopes, + content: content, + configuration: configuration + ) + } + } + var contexts = [ChatContext]() + for await context in group { + contexts.append(context) + } + return contexts } + + let extraSystemPrompt = contexts + .map(\.systemPrompt) + .filter { !$0.isEmpty } + .joined(separator: "\n") + + let contextPrompts = contexts + .flatMap(\.retrievedContent) + .filter { !$0.content.isEmpty } + .sorted { $0.priority > $1.priority } + let contextualSystemPrompt = """ \(language.isEmpty ? "" : "You must always reply in \(language)") - \(systemPrompt) - - \(contexts.map(\.systemPrompt).filter { !$0.isEmpty }.joined(separator: "\n\n")) + \(systemPrompt)\(extraSystemPrompt.isEmpty ? "" : "\n\(extraSystemPrompt)") """ await memory.mutateSystemPrompt(contextualSystemPrompt) + await memory.mutateRetrievedContent(contextPrompts.map(\.content)) functionProvider.append(functions: contexts.flatMap(\.functions)) } } diff --git a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManagementView.swift b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManagementView.swift index 101c4e49..48bd8632 100644 --- a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManagementView.swift +++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManagementView.swift @@ -1,4 +1,5 @@ import ComposableArchitecture +import SharedUIComponents import SwiftUI struct APIKeyManagementView: View { @@ -49,6 +50,13 @@ struct APIKeyManagementView: View { .buttonStyle(.plain) } } + .modify { view in + if #available(macOS 13.0, *) { + view.listRowSeparator(.hidden).listSectionSeparator(.hidden) + } else { + view + } + } } } .removeBackground() diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagementView.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagementView.swift index cac7184f..6101de58 100644 --- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagementView.swift +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagementView.swift @@ -12,7 +12,7 @@ struct ChatModelManagementView: View { action: ChatModelManagement.Action.chatModelItem )) { store in ChatModelEditView(store: store) - .frame(minWidth: 400) + .frame(width: 800) } } } diff --git a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/AIModelManagementVIew.swift b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/AIModelManagementVIew.swift index 37bcac29..621ed75d 100644 --- a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/AIModelManagementVIew.swift +++ b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/AIModelManagementVIew.swift @@ -1,6 +1,7 @@ import AIModel import ComposableArchitecture import PlusFeatureFlag +import SharedUIComponents import SwiftUI protocol AIModelManagementAction { @@ -54,7 +55,7 @@ struct AIModelManagementView= 2 - + Button(disabled ? "Add More Model (Plus)" : "Add Model") { store.send(.createModel) }.disabled(disabled) @@ -102,6 +103,13 @@ struct AIModelManagementView { - Scope(state: \.suggestionWidgetState, action: /Action.suggestionWidget) { - WidgetFeature() - } + CombineReducers { + Scope(state: \.suggestionWidgetState, action: /Action.suggestionWidget) { + WidgetFeature() + } - Scope( - state: \.chatTabGroup, - action: /Action.suggestionWidget .. /WidgetFeature.Action.chatPanel - ) { - Reduce { _, action in - switch action { - case let .createNewTapButtonClicked(kind): - return .run { send in - if let (_, chatTabInfo) = await chatTabPool.createTab(for: kind) { - await send(.appendAndSelectTab(chatTabInfo)) + Scope( + state: \.chatTabGroup, + action: /Action.suggestionWidget .. /WidgetFeature.Action.chatPanel + ) { + Reduce { _, action in + switch action { + case let .createNewTapButtonClicked(kind): + return .run { send in + if let (_, chatTabInfo) = await chatTabPool.createTab(for: kind) { + await send(.appendAndSelectTab(chatTabInfo)) + } } - } - case let .closeTabButtonClicked(id): - return .run { _ in - chatTabPool.removeTab(of: id) - } + case let .closeTabButtonClicked(id): + return .run { _ in + chatTabPool.removeTab(of: id) + } - case let .chatTab(_, .openNewTab(builder)): - return .run { send in - if let (_, chatTabInfo) = await chatTabPool - .createTab(from: builder.chatTabBuilder) - { - await send(.appendAndSelectTab(chatTabInfo)) + case let .chatTab(_, .openNewTab(builder)): + return .run { send in + if let (_, chatTabInfo) = await chatTabPool + .createTab(from: builder.chatTabBuilder) + { + await send(.appendAndSelectTab(chatTabInfo)) + } } - } - default: - return .none + default: + return .none + } } } - } - #if canImport(ChatTabPersistent) - Scope(state: \.persistentState, action: /Action.persistent) { - ChatTabPersistent() - } - #endif + #if canImport(ChatTabPersistent) + Scope(state: \.persistentState, action: /Action.persistent) { + ChatTabPersistent() + } + #endif - Reduce { state, action in - switch action { - case .start: - #if canImport(ChatTabPersistent) - return .run { send in - await send(.persistent(.restoreChatTabs)) - } - #else - return .none - #endif + Reduce { state, action in + switch action { + case .start: + #if canImport(ChatTabPersistent) + return .run { send in + await send(.persistent(.restoreChatTabs)) + } + #else + return .none + #endif - case let .openChatPanel(forceDetach): - return .run { send in - await send( - .suggestionWidget(.chatPanel(.presentChatPanel(forceDetach: forceDetach))) - ) - } + case let .openChatPanel(forceDetach): + return .run { send in + await send( + .suggestionWidget( + .chatPanel(.presentChatPanel(forceDetach: forceDetach)) + ) + ) + } - case .createChatGPTChatTabIfNeeded: - if state.chatTabGroup.tabInfo.contains(where: { - chatTabPool.getTab(of: $0.id) is ChatGPTChatTab - }) { - return .none - } - return .run { send in - if let (_, chatTabInfo) = await chatTabPool.createTab(for: nil) { - await send(.suggestionWidget(.chatPanel(.appendAndSelectTab(chatTabInfo)))) + case .createChatGPTChatTabIfNeeded: + if state.chatTabGroup.tabInfo.contains(where: { + chatTabPool.getTab(of: $0.id) is ChatGPTChatTab + }) { + return .none + } + return .run { send in + if let (_, chatTabInfo) = await chatTabPool.createTab(for: nil) { + await send( + .suggestionWidget(.chatPanel(.appendAndSelectTab(chatTabInfo))) + ) + } } - } - case let .sendCustomCommandToActiveChat(command): - @Sendable func stopAndHandleCommand(_ tab: ChatGPTChatTab) async { - if tab.service.isReceivingMessage { - await tab.service.stopReceivingMessage() + case let .sendCustomCommandToActiveChat(command): + @Sendable func stopAndHandleCommand(_ tab: ChatGPTChatTab) async { + if tab.service.isReceivingMessage { + await tab.service.stopReceivingMessage() + } + try? await tab.service.handleCustomCommand(command) } - try? await tab.service.handleCustomCommand(command) - } - if let info = state.chatTabGroup.selectedTabInfo, - let activeTab = chatTabPool.getTab(of: info.id) as? ChatGPTChatTab - { - return .run { send in - await send(.openChatPanel(forceDetach: false)) - await stopAndHandleCommand(activeTab) + if let info = state.chatTabGroup.selectedTabInfo, + let activeTab = chatTabPool.getTab(of: info.id) as? ChatGPTChatTab + { + return .run { send in + await send(.openChatPanel(forceDetach: false)) + await stopAndHandleCommand(activeTab) + } + } + + if let info = state.chatTabGroup.tabInfo.first(where: { + chatTabPool.getTab(of: $0.id) is ChatGPTChatTab + }), + let chatTab = chatTabPool.getTab(of: info.id) as? ChatGPTChatTab + { + state.chatTabGroup.selectedTabId = chatTab.id + return .run { send in + await send(.openChatPanel(forceDetach: false)) + await stopAndHandleCommand(chatTab) + } } - } - if let info = state.chatTabGroup.tabInfo.first(where: { - chatTabPool.getTab(of: $0.id) is ChatGPTChatTab - }), - let chatTab = chatTabPool.getTab(of: info.id) as? ChatGPTChatTab - { - state.chatTabGroup.selectedTabId = chatTab.id return .run { send in + guard let (chatTab, chatTabInfo) = await chatTabPool.createTab(for: nil) + else { + return + } + await send(.suggestionWidget(.chatPanel(.appendAndSelectTab(chatTabInfo)))) await send(.openChatPanel(forceDetach: false)) - await stopAndHandleCommand(chatTab) + if let chatTab = chatTab as? ChatGPTChatTab { + await stopAndHandleCommand(chatTab) + } } - } - return .run { send in - guard let (chatTab, chatTabInfo) = await chatTabPool.createTab(for: nil) else { - return + case let .suggestionWidget(.chatPanel(.chatTab(id, .tabContentUpdated))): + #if canImport(ChatTabPersistent) + // when a tab is updated, persist it. + return .run { send in + await send(.persistent(.chatTabUpdated(id: id))) } - await send(.suggestionWidget(.chatPanel(.appendAndSelectTab(chatTabInfo)))) - await send(.openChatPanel(forceDetach: false)) - if let chatTab = chatTab as? ChatGPTChatTab { - await stopAndHandleCommand(chatTab) + #else + return .none + #endif + + case let .suggestionWidget(.chatPanel(.closeTabButtonClicked(id))): + #if canImport(ChatTabPersistent) + // when a tab is closed, remove it from persistence. + return .run { send in + await send(.persistent(.chatTabClosed(id: id))) } - } + #else + return .none + #endif + + case .suggestionWidget: + return .none - case let .suggestionWidget(.chatPanel(.chatTab(id, .tabContentUpdated))): #if canImport(ChatTabPersistent) - // when a tab is updated, persist it. - return .run { send in - await send(.persistent(.chatTabUpdated(id: id))) - } - #else - return .none + case .persistent: + return .none #endif - - case let .suggestionWidget(.chatPanel(.closeTabButtonClicked(id))): + } + } + }.onChange(of: \.chatTabGroup.tabInfo) { old, new in + Reduce { _, _ in + guard old.map(\.id) != new.map(\.id) else { + return .none + } #if canImport(ChatTabPersistent) - // when a tab is closed, remove it from persistence. return .run { send in - await send(.persistent(.chatTabClosed(id: id))) - } + await send(.persistent(.chatOrderChanged)) + }.debounce(id: Debounce.updateChatTabOrder, for: 1, scheduler: DispatchQueue.main) #else return .none #endif - - case .suggestionWidget: - return .none - - #if canImport(ChatTabPersistent) - case .persistent: - return .none - #endif } } } diff --git a/Core/Sources/Service/RealtimeSuggestionController.swift b/Core/Sources/Service/RealtimeSuggestionController.swift index e0069d84..2b529c2b 100644 --- a/Core/Sources/Service/RealtimeSuggestionController.swift +++ b/Core/Sources/Service/RealtimeSuggestionController.swift @@ -21,27 +21,43 @@ public actor RealtimeSuggestionController { private var sourceEditor: SourceEditor? init() {} + + deinit { + task?.cancel() + inflightPrefetchTask?.cancel() + windowChangeObservationTask?.cancel() + activeApplicationMonitorTask?.cancel() + editorObservationTask?.cancel() + } nonisolated func start() { - Task { [weak self] in - if let app = ActiveApplicationMonitor.shared.activeXcode { - await self?.handleXcodeChanged(app) + Task { await observeXcodeChange() } + } + + private func observeXcodeChange() { + task?.cancel() + task = Task { [weak self] in + if ActiveApplicationMonitor.shared.activeXcode != nil { + await self?.handleXcodeChanged() } - var previousApp = ActiveApplicationMonitor.shared.activeXcode - for await app in ActiveApplicationMonitor.shared.createStream() { + var previousApp = ActiveApplicationMonitor.shared.activeXcode?.info + for await app in ActiveApplicationMonitor.shared.createInfoStream() { guard let self else { return } try Task.checkCancellation() defer { previousApp = app } - if let app = ActiveApplicationMonitor.shared.activeXcode, app != previousApp { - await self.handleXcodeChanged(app) + if let app = ActiveApplicationMonitor.shared.activeXcode, + app.processIdentifier != previousApp?.processIdentifier + { + await self.handleXcodeChanged() } } } } - private func handleXcodeChanged(_ app: NSRunningApplication) { + private func handleXcodeChanged() { + guard let app = ActiveApplicationMonitor.shared.activeXcode else { return } windowChangeObservationTask?.cancel() windowChangeObservationTask = nil observeXcodeWindowChangeIfNeeded(app) @@ -50,12 +66,13 @@ public actor RealtimeSuggestionController { private func observeXcodeWindowChangeIfNeeded(_ app: NSRunningApplication) { guard windowChangeObservationTask == nil else { return } handleFocusElementChange() + + let notifications = AXNotificationStream( + app: app, + notificationNames: kAXFocusedUIElementChangedNotification, + kAXMainWindowChangedNotification + ) windowChangeObservationTask = Task { [weak self] in - let notifications = AXNotificationStream( - app: app, - notificationNames: kAXFocusedUIElementChangedNotification, - kAXMainWindowChangedNotification - ) for await _ in notifications { guard let self else { return } try Task.checkCancellation() @@ -84,6 +101,12 @@ public actor RealtimeSuggestionController { editorObservationTask?.cancel() editorObservationTask = nil + let notificationsFromEditor = AXNotificationStream( + app: activeXcode, + element: focusElement, + notificationNames: kAXValueChangedNotification, kAXSelectedTextChangedNotification + ) + editorObservationTask = Task { [weak self] in let fileURL = try await Environment.fetchCurrentFileURL() if let sourceEditor = await self?.sourceEditor { @@ -93,12 +116,6 @@ public actor RealtimeSuggestionController { ) } - let notificationsFromEditor = AXNotificationStream( - app: activeXcode, - element: focusElement, - notificationNames: kAXValueChangedNotification, kAXSelectedTextChangedNotification - ) - for await notification in notificationsFromEditor { guard let self else { return } try Task.checkCancellation() diff --git a/Core/Sources/Service/ScheduledCleaner.swift b/Core/Sources/Service/ScheduledCleaner.swift index 35a86543..66f1d438 100644 --- a/Core/Sources/Service/ScheduledCleaner.swift +++ b/Core/Sources/Service/ScheduledCleaner.swift @@ -19,7 +19,6 @@ public final class ScheduledCleaner { } func start() { - // occasionally cleanup workspaces. Task { @ServiceActor in while !Task.isCancelled { try await Task.sleep(nanoseconds: 10 * 60 * 1_000_000_000) @@ -27,9 +26,8 @@ public final class ScheduledCleaner { } } - // cleanup when Xcode becomes inactive Task { @ServiceActor in - for await app in ActiveApplicationMonitor.shared.createStream() { + for await app in ActiveApplicationMonitor.shared.createInfoStream() { try Task.checkCancellation() if let app, !app.isXcode { await cleanUp() diff --git a/Core/Sources/Service/Service.swift b/Core/Sources/Service/Service.swift index 9c586e6b..04a72f6c 100644 --- a/Core/Sources/Service/Service.swift +++ b/Core/Sources/Service/Service.swift @@ -1,10 +1,10 @@ import Dependencies import Foundation import Workspace +import WorkspaceSuggestionService -#if canImport(KeyBindingManager) -import EnhancedWorkspace -import KeyBindingManager +#if canImport(ProService) +import ProService #endif @globalActor public enum ServiceActor { @@ -22,33 +22,27 @@ public final class Service { public let guiController = GraphicalUserInterfaceController() public let realtimeSuggestionController = RealtimeSuggestionController() public let scheduledCleaner: ScheduledCleaner - #if canImport(KeyBindingManager) - let keyBindingManager: KeyBindingManager + + #if canImport(ProService) + let proService: ProService #endif private init() { @Dependency(\.workspacePool) var workspacePool scheduledCleaner = .init(workspacePool: workspacePool, guiController: guiController) - #if canImport(KeyBindingManager) - keyBindingManager = .init( - workspacePool: workspacePool, - acceptSuggestion: { - Task { - await PseudoCommandHandler().acceptSuggestion() - } - } - ) - #endif - workspacePool.registerPlugin { SuggestionServiceWorkspacePlugin(workspace: $0) } - #if canImport(EnhancedWorkspace) - if !UserDefaults.shared.value(for: \.disableEnhancedWorkspace) { - workspacePool.registerPlugin { EnhancedWorkspacePlugin(workspace: $0) } + self.workspacePool = workspacePool + + #if canImport(ProService) + proService = withDependencies { dependencyValues in + dependencyValues.proServiceAcceptSuggestion = { + Task { await PseudoCommandHandler().acceptSuggestion() } + } + } operation: { + ProService() } #endif - - self.workspacePool = workspacePool } @MainActor @@ -56,8 +50,8 @@ public final class Service { scheduledCleaner.start() realtimeSuggestionController.start() guiController.start() - #if canImport(KeyBindingManager) - keyBindingManager.start() + #if canImport(ProService) + proService.start() #endif DependencyUpdater().update() } diff --git a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift index 99a2e269..5161bcec 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift @@ -5,6 +5,7 @@ import Preferences import SuggestionInjector import SuggestionModel import Workspace +import WorkspaceSuggestionService import XcodeInspector import XPCShared diff --git a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift index 91c72710..aa271874 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift @@ -10,6 +10,7 @@ import SuggestionModel import SuggestionWidget import UserNotifications import Workspace +import WorkspaceSuggestionService import XPCShared struct WindowBaseCommandHandler: SuggestionCommandHandler { diff --git a/Core/Sources/Service/WorkspaceExtension/Workspace+Cleanup.swift b/Core/Sources/Service/WorkspaceExtension/Workspace+Cleanup.swift index 3a5475da..a2f439d1 100644 --- a/Core/Sources/Service/WorkspaceExtension/Workspace+Cleanup.swift +++ b/Core/Sources/Service/WorkspaceExtension/Workspace+Cleanup.swift @@ -1,6 +1,7 @@ import Foundation import Workspace import SuggestionService +import WorkspaceSuggestionService extension Workspace { @WorkspaceActor diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift index 0b610bae..f82d7a89 100644 --- a/Core/Sources/SuggestionWidget/ChatWindowView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift @@ -66,9 +66,7 @@ struct ChatTitleBar: View { : Color(nsColor: .disabledControlTextColor) ) .frame(width: 10, height: 10) - .overlay { - Circle().strokeBorder(.black.opacity(0.3), lineWidth: 1) - } + .shadow(radius: 0.5) .overlay { if isHovering { Image(systemName: "minus") @@ -91,9 +89,7 @@ struct ChatTitleBar: View { : Color(nsColor: .disabledControlTextColor) ) .frame(width: 10, height: 10) - .overlay { - Circle().strokeBorder(.black.opacity(0.3), lineWidth: 1) - } + .shadow(radius: 0.5) .disabled(!viewStore.state) .overlay { if isHovering { @@ -160,6 +156,7 @@ struct ChatTabBar: View { } @Environment(\.chatTabPool) var chatTabPool + @State var draggingTabId: String? var body: some View { WithViewStore( @@ -186,6 +183,20 @@ struct ChatTabBar: View { tab.menu } .id(info.id) + .onDrag { + draggingTabId = info.id + return NSItemProvider(object: info.id as NSString) + } + .onDrop( + of: [.text], + delegate: ChatTabBarDropDelegate( + store: store, + tabs: viewStore.state.tabInfo, + itemId: info.id, + draggingTabId: $draggingTabId + ) + ) + } else { EmptyView() } @@ -264,6 +275,30 @@ struct ChatTabBar: View { } } +struct ChatTabBarDropDelegate: DropDelegate { + let store: StoreOf + let tabs: IdentifiedArray + let itemId: String + @Binding var draggingTabId: String? + + func dropUpdated(info: DropInfo) -> DropProposal? { + return DropProposal(operation: .move) + } + + func performDrop(info: DropInfo) -> Bool { + draggingTabId = nil + return true + } + + func dropEntered(info: DropInfo) { + guard itemId != draggingTabId else { return } + let from = tabs.firstIndex { $0.id == draggingTabId } + let to = tabs.firstIndex { $0.id == itemId } + guard let from, let to, from != to else { return } + store.send(.moveChatTab(from: from, to: to)) + } +} + struct ChatTabBarButton: View { let store: StoreOf let info: ChatTabInfo @@ -273,33 +308,30 @@ struct ChatTabBarButton: View { var body: some View { HStack(spacing: 0) { - Button(action: { - store.send(.tabClicked(id: info.id)) - }) { - content() - .font(.callout) - .lineLimit(1) - .frame(maxWidth: 120) - .padding(.horizontal, 32) - .contentShape(Rectangle()) - } - .buttonStyle(PlainButtonStyle()) - - .overlay(alignment: .leading) { - Button(action: { - store.send(.closeTabButtonClicked(id: info.id)) - }) { - Image(systemName: "xmark") - .foregroundColor(.secondary) + content() + .font(.callout) + .lineLimit(1) + .frame(maxWidth: 120) + .padding(.horizontal, 32) + .contentShape(Rectangle()) + .onTapGesture { + store.send(.tabClicked(id: info.id)) } - .buttonStyle(.plain) - .padding(2) - .padding(.leading, 8) - .opacity(isHovered ? 1 : 0) - } - .onHover { isHovered = $0 } - .animation(.linear(duration: 0.1), value: isHovered) - .animation(.linear(duration: 0.1), value: isSelected) + .overlay(alignment: .leading) { + Button(action: { + store.send(.closeTabButtonClicked(id: info.id)) + }) { + Image(systemName: "xmark") + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + .padding(2) + .padding(.leading, 8) + .opacity(isHovered ? 1 : 0) + } + .onHover { isHovered = $0 } + .animation(.linear(duration: 0.1), value: isHovered) + .animation(.linear(duration: 0.1), value: isSelected) Divider().padding(.vertical, 6) } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift index e6f2a517..b63d74b3 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift @@ -27,7 +27,7 @@ public struct ChatPanelFeature: ReducerProtocol { public var tabInfo: IdentifiedArray public var tabCollection: [ChatTabBuilderCollection] public var selectedTabId: String? - + public var selectedTabInfo: ChatTabInfo? { guard let id = selectedTabId else { return tabInfo.first } return tabInfo[id: id] @@ -69,7 +69,8 @@ public struct ChatPanelFeature: ReducerProtocol { case appendAndSelectTab(ChatTabInfo) case switchToNextTab case switchToPreviousTab - + case moveChatTab(from: Int, to: Int) + case chatTab(id: String, action: ChatTabItem.Action) } @@ -205,7 +206,18 @@ public struct ChatPanelFeature: ReducerProtocol { let targetId = state.chatTabGroup.tabInfo[previousIndex].id state.chatTabGroup.selectedTabId = targetId return .none - + + case let .moveChatTab(from, to): + guard from >= 0, from < state.chatTabGroup.tabInfo.endIndex, to >= 0, + to <= state.chatTabGroup.tabInfo.endIndex + else { + return .none + } + let tab = state.chatTabGroup.tabInfo[from] + state.chatTabGroup.tabInfo.remove(at: from) + state.chatTabGroup.tabInfo.insert(tab, at: to) + return .none + case .chatTab: return .none } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift index d89dfeb5..bee2092e 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift @@ -79,7 +79,7 @@ public struct WidgetFeature: ReducerProtocol { ) } } - + var lastUpdateWindowOpacityTime = Date(timeIntervalSince1970: 0) public init() {} @@ -213,10 +213,10 @@ public struct WidgetFeature: ReducerProtocol { case .observeActiveApplicationChange: return .run { send in - var previousApp: NSRunningApplication? - for await app in activeApplicationMonitor.createStream() { + var previousApp: RunningApplicationInfo? + for await app in activeApplicationMonitor.createInfoStream() { try Task.checkCancellation() - if app != previousApp { + if app?.processIdentifier != previousApp?.processIdentifier { await send(.updateActiveApplication) } previousApp = app @@ -311,26 +311,26 @@ public struct WidgetFeature: ReducerProtocol { case .observeWindowChange: guard let app = activeApplicationMonitor.activeApplication else { return .none } guard app.isXcode else { return .none } - + let documentURL = state.focusingDocumentURL - return .run { [app] send in - await send(.observeEditorChange) + let notifications = AXNotificationStream( + app: app, + notificationNames: + kAXApplicationActivatedNotification, + kAXMovedNotification, + kAXResizedNotification, + kAXMainWindowChangedNotification, + kAXFocusedWindowChangedNotification, + kAXFocusedUIElementChangedNotification, + kAXWindowMovedNotification, + kAXWindowResizedNotification, + kAXWindowMiniaturizedNotification, + kAXWindowDeminiaturizedNotification + ) - let notifications = AXNotificationStream( - app: app, - notificationNames: - kAXApplicationActivatedNotification, - kAXMovedNotification, - kAXResizedNotification, - kAXMainWindowChangedNotification, - kAXFocusedWindowChangedNotification, - kAXFocusedUIElementChangedNotification, - kAXWindowMovedNotification, - kAXWindowResizedNotification, - kAXWindowMiniaturizedNotification, - kAXWindowDeminiaturizedNotification - ) + return .run { send in + await send(.observeEditorChange) for await notification in notifications { try Task.checkCancellation() @@ -369,45 +369,46 @@ public struct WidgetFeature: ReducerProtocol { case .observeEditorChange: guard let app = activeApplicationMonitor.activeApplication else { return .none } - return .run { send in - let appElement = AXUIElementCreateApplication(app.processIdentifier) - if let focusedElement = appElement.focusedElement, - focusedElement.description == "Source Editor", - let scrollView = focusedElement.parent, - let scrollBar = scrollView.verticalScrollBar - { - let selectionRangeChange = AXNotificationStream( - app: app, - element: focusedElement, - notificationNames: kAXSelectedTextChangedNotification - ) - let scroll = AXNotificationStream( - app: app, - element: scrollBar, - notificationNames: kAXValueChangedNotification - ) + let appElement = AXUIElementCreateApplication(app.processIdentifier) + guard let focusedElement = appElement.focusedElement, + focusedElement.description == "Source Editor", + let scrollView = focusedElement.parent, + let scrollBar = scrollView.verticalScrollBar + else { return .none } + + let selectionRangeChange = AXNotificationStream( + app: app, + element: focusedElement, + notificationNames: kAXSelectedTextChangedNotification + ) + let scroll = AXNotificationStream( + app: app, + element: scrollBar, + notificationNames: kAXValueChangedNotification + ) - if #available(macOS 13.0, *) { - for await _ in merge( - selectionRangeChange.debounce(for: Duration.milliseconds(500)), - scroll - ) { - guard activeApplicationMonitor.latestXcode != nil - else { return } - try Task.checkCancellation() - await send(.updateWindowLocation(animated: false)) - await send(.updateWindowOpacity) - } - } else { - for await _ in merge(selectionRangeChange, scroll) { - guard activeApplicationMonitor.latestXcode != nil - else { return } - try Task.checkCancellation() - await send(.updateWindowLocation(animated: false)) - await send(.updateWindowOpacity) - } + return .run { send in + if #available(macOS 13.0, *) { + for await _ in merge( + selectionRangeChange.debounce(for: Duration.milliseconds(500)), + scroll + ) { + guard activeApplicationMonitor.latestXcode != nil + else { return } + try Task.checkCancellation() + await send(.updateWindowLocation(animated: false)) + await send(.updateWindowOpacity) + } + } else { + for await _ in merge(selectionRangeChange, scroll) { + guard activeApplicationMonitor.latestXcode != nil + else { return } + try Task.checkCancellation() + await send(.updateWindowLocation(animated: false)) + await send(.updateWindowOpacity) } } + }.cancellable(id: CancelID.observeEditorChange, cancelInFlight: true) case .updateActiveApplication: @@ -447,7 +448,7 @@ public struct WidgetFeature: ReducerProtocol { state.panelState.suggestionPanelState.colorScheme = scheme state.chatPanelState.colorScheme = scheme return .none - + case .updateFocusingDocumentURL: state.focusingDocumentURL = xcodeInspector.realtimeActiveDocumentURL return .none @@ -574,7 +575,7 @@ public struct WidgetFeature: ReducerProtocol { await send(.updateWindowOpacityFinished) } .cancellable(id: DebounceKey.updateWindowOpacity, cancelInFlight: true) - + case .updateWindowOpacityFinished: state.lastUpdateWindowOpacityTime = Date() return .none diff --git a/LICENSE b/LICENSE index a8b6fd3c..bdeb59bb 100644 --- a/LICENSE +++ b/LICENSE @@ -9,7 +9,7 @@ a. may be subject to a more permissive open-source license in the future. b. can be used for commercial purposes. With the GPLv3 and these supplementary agreements, anyone can freely use, modify, and distribute the project, provided that: -- For commercial use or commercial forks of this project, please contact us for authorization. +- For commercial redistribution or commercial forks of this project, please contact us for authorization. Copyright (c) 2023 Shangxin Guo diff --git a/Pro b/Pro index f4d8042a..72fd9411 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit f4d8042af810ab9a0a8847d5f961ce10cb0a0e1e +Subproject commit 72fd9411fb566b45e8dbb2df418cb5ef7a99d5cd diff --git a/TestPlan.xctestplan b/TestPlan.xctestplan index 77034ff3..15a5c4cf 100644 --- a/TestPlan.xctestplan +++ b/TestPlan.xctestplan @@ -50,13 +50,6 @@ "name" : "PromptToCodeServiceTests" } }, - { - "target" : { - "containerPath" : "container:Core", - "identifier" : "GitHubCopilotServiceTests", - "name" : "GitHubCopilotServiceTests" - } - }, { "target" : { "containerPath" : "container:Tool", @@ -99,13 +92,6 @@ "name" : "SharedUIComponentsTests" } }, - { - "target" : { - "containerPath" : "container:Core", - "identifier" : "ActiveDocumentChatContextCollectorTests", - "name" : "ActiveDocumentChatContextCollectorTests" - } - }, { "target" : { "containerPath" : "container:Tool", @@ -126,6 +112,20 @@ "identifier" : "KeychainTests", "name" : "KeychainTests" } + }, + { + "target" : { + "containerPath" : "container:Tool", + "identifier" : "ActiveDocumentChatContextCollectorTests", + "name" : "ActiveDocumentChatContextCollectorTests" + } + }, + { + "target" : { + "containerPath" : "container:Tool", + "identifier" : "GitHubCopilotServiceTests", + "name" : "GitHubCopilotServiceTests" + } } ], "version" : 1 diff --git a/Tool/Package.resolved b/Tool/Package.resolved new file mode 100644 index 00000000..141e454d --- /dev/null +++ b/Tool/Package.resolved @@ -0,0 +1,266 @@ +{ + "pins" : [ + { + "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" : "ec62f32d21584214a4b27c8cee2b2ad70ab2c38a", + "version" : "0.11.0" + } + }, + { + "identity" : "fseventswrapper", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Frizlab/FSEventsWrapper", + "state" : { + "revision" : "e0c59a2ce2775e5f6642da6d19207445f10112d0", + "version" : "1.0.2" + } + }, + { + "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" : "bump-highlight-js-version", + "revision" : "4ffbb1b0b721378263297cafea6f2838044eb1eb" + } + }, + { + "identity" : "jsonrpc", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ChimeHQ/JSONRPC", + "state" : { + "revision" : "5da978702aece6ba5c7879b0d253c180d61e4ef3", + "version" : "0.6.0" + } + }, + { + "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" : "operationplus", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ChimeHQ/OperationPlus", + "state" : { + "revision" : "1340f95dce3e93d742497d88db18f8676f4badf4", + "version" : "1.6.0" + } + }, + { + "identity" : "processenv", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ChimeHQ/ProcessEnv", + "state" : { + "revision" : "29487b6581bb785c372c611c943541ef4309d051", + "version" : "0.3.1" + } + }, + { + "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" : "0fbaebfc013715dab44d715a4d350ba37f297e4d", + "version" : "0.4.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" : "9f4202ab5b8422aa90f0ed983bf7652c3af7abf0", + "version" : "0.59.0" + } + }, + { + "identity" : "swift-concurrency-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras", + "state" : { + "revision" : "479750bd98fac2e813fffcf2af0728b5b0085795", + "version" : "0.1.1" + } + }, + { + "identity" : "swift-custom-dump", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-custom-dump", + "state" : { + "revision" : "4a87bb75be70c983a9548597e8783236feb3401e", + "version" : "0.11.1" + } + }, + { + "identity" : "swift-dependencies", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-dependencies", + "state" : { + "revision" : "16fd42ae04c6e7f74a6a86395d04722c641cccee", + "version" : "0.6.0" + } + }, + { + "identity" : "swift-identified-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-identified-collections", + "state" : { + "revision" : "d01446a78fb768adc9a78cbb6df07767c8ccfc29", + "version" : "0.8.0" + } + }, + { + "identity" : "swift-parsing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-parsing", + "state" : { + "revision" : "27c941bbd22a4bbc53005a15a0440443fd892f70", + "version" : "0.12.1" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-syntax.git", + "state" : { + "branch" : "main", + "revision" : "e149b01cfd3e96240e102729697e2095c19157ef" + } + }, + { + "identity" : "swiftsoup", + "kind" : "remoteSourceControl", + "location" : "https://github.com/scinfu/SwiftSoup.git", + "state" : { + "revision" : "8b6cf29eead8841a1fa7822481cb3af4ddaadba6", + "version" : "2.6.1" + } + }, + { + "identity" : "swifttreesitter", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ChimeHQ/SwiftTreeSitter", + "state" : { + "revision" : "a9b1335d5151b62b11f07599bd07d07dc5965de3", + "version" : "0.7.2" + } + }, + { + "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" : "tree-sitter-objc", + "kind" : "remoteSourceControl", + "location" : "https://github.com/lukepistrol/tree-sitter-objc", + "state" : { + "branch" : "feature/spm", + "revision" : "1b54ef0b5efddddf393b45e173788499cc572048" + } + }, + { + "identity" : "tree-sitter-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/alex-pinkus/tree-sitter-swift", + "state" : { + "branch" : "with-generated-files", + "revision" : "eda05af7ac41adb4eb19c346883c0fa32fe3bdd8" + } + }, + { + "identity" : "usearch", + "kind" : "remoteSourceControl", + "location" : "https://github.com/unum-cloud/usearch", + "state" : { + "revision" : "33c53288b44ccb55de77776820676132a6e4c42a", + "version" : "0.23.0" + } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state" : { + "revision" : "50843cbb8551db836adec2290bb4bc6bac5c1865", + "version" : "0.9.0" + } + } + ], + "version" : 2 +} diff --git a/Tool/Package.swift b/Tool/Package.swift index 5f5218a6..d8af318b 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -7,6 +7,7 @@ let package = Package( name: "Tool", platforms: [.macOS(.v12)], products: [ + .library(name: "XPCShared", targets: ["XPCShared"]), .library(name: "Terminal", targets: ["Terminal"]), .library(name: "LangChain", targets: ["LangChain"]), .library(name: "ExternalServices", targets: ["BingSearchService"]), @@ -14,7 +15,10 @@ let package = Package( .library(name: "Logger", targets: ["Logger"]), .library(name: "OpenAIService", targets: ["OpenAIService"]), .library(name: "ChatTab", targets: ["ChatTab"]), - .library(name: "ChatContextCollector", targets: ["ChatContextCollector"]), + .library( + name: "ChatContextCollector", + targets: ["ChatContextCollector", "ActiveDocumentChatContextCollector"] + ), .library(name: "Environment", targets: ["Environment"]), .library(name: "SuggestionModel", targets: ["SuggestionModel"]), .library(name: "ASTParser", targets: ["ASTParser"]), @@ -23,7 +27,11 @@ let package = Package( .library(name: "Keychain", targets: ["Keychain"]), .library(name: "SharedUIComponents", targets: ["SharedUIComponents"]), .library(name: "UserDefaultsObserver", targets: ["UserDefaultsObserver"]), - .library(name: "Workspace", targets: ["Workspace"]), + .library(name: "Workspace", targets: ["Workspace", "WorkspaceSuggestionService"]), + .library( + name: "SuggestionService", + targets: ["SuggestionService", "GitHubCopilotService", "CodeiumService"] + ), .library( name: "AppMonitoring", targets: [ @@ -37,7 +45,9 @@ let package = Package( dependencies: [ // A fork of https://github.com/aespinilla/Tiktoken to allow loading from local files. .package(url: "https://github.com/intitni/Tiktoken", branch: "main"), + // TODO: Update LanguageClient some day. .package(url: "https://github.com/ChimeHQ/LanguageClient", exact: "0.3.1"), + .package(url: "https://github.com/ChimeHQ/LanguageServerProtocol", exact: "0.8.0"), .package(url: "https://github.com/apple/swift-async-algorithms", from: "0.1.0"), .package(url: "https://github.com/pointfreeco/swift-parsing", from: "0.12.1"), .package(url: "https://github.com/ChimeHQ/JSONRPC", exact: "0.6.0"), @@ -62,6 +72,8 @@ let package = Package( targets: [ // MARK: - Helpers + .target(name: "XPCShared", dependencies: ["SuggestionModel"]), + .target(name: "Configs"), .target(name: "Preferences", dependencies: ["Configs", "AIModel"]), @@ -189,7 +201,16 @@ let package = Package( "Environment", "Logger", "Preferences", - "XcodeInspector" + "XcodeInspector", + ] + ), + + .target( + name: "WorkspaceSuggestionService", + dependencies: [ + "Workspace", + "SuggestionService", + "XPCShared", ] ), @@ -216,15 +237,45 @@ let package = Package( ] ), + .target(name: "BingSearchService"), + + .target(name: "SuggestionService", dependencies: [ + "GitHubCopilotService", + "CodeiumService", + "UserDefaultsObserver", + ]), + + // MARK: - GitHub Copilot + .target( - name: "ChatContextCollector", + name: "GitHubCopilotService", dependencies: [ + "LanguageClient", "SuggestionModel", - "OpenAIService", + "Logger", + "Preferences", + "Terminal", + .product(name: "LanguageServerProtocol", package: "LanguageServerProtocol"), ] ), + .testTarget( + name: "GitHubCopilotServiceTests", + dependencies: ["GitHubCopilotService"] + ), - .target(name: "BingSearchService"), + // MARK: - Codeium + + .target( + name: "CodeiumService", + dependencies: [ + "LanguageClient", + "Keychain", + "SuggestionModel", + "Preferences", + "Terminal", + "XcodeInspector", + ] + ), // MARK: - OpenAI @@ -254,6 +305,33 @@ let package = Package( )] ), + // MARK: - Chat Context Collector + + .target( + name: "ChatContextCollector", + dependencies: [ + "SuggestionModel", + "OpenAIService", + ] + ), + + .target( + name: "ActiveDocumentChatContextCollector", + dependencies: [ + "ChatContextCollector", + "OpenAIService", + "Preferences", + "FocusedCodeFinder", + "XcodeInspector", + ], + path: "Sources/ChatContextCollectors/ActiveDocumentChatContextCollector" + ), + + .testTarget( + name: "ActiveDocumentChatContextCollectorTests", + dependencies: ["ActiveDocumentChatContextCollector"] + ), + // MARK: - Tests .testTarget( diff --git a/Tool/Sources/AXNotificationStream/AXNotificationStream.swift b/Tool/Sources/AXNotificationStream/AXNotificationStream.swift index ab338be8..77df9b66 100644 --- a/Tool/Sources/AXNotificationStream/AXNotificationStream.swift +++ b/Tool/Sources/AXNotificationStream/AXNotificationStream.swift @@ -86,12 +86,14 @@ public final class AXNotificationStream: AsyncSequence { guard let self else { return } retry += 1 for name in notificationNames { - let e = AXObserverAddNotification( - observer, - observingElement, - name as CFString, - &self.continuation - ) + let e = withUnsafeMutablePointer(to: &self.continuation) { pointer in + AXObserverAddNotification( + observer, + observingElement, + name as CFString, + pointer + ) + } switch e { case .success: pendingRegistrationNames.remove(name) diff --git a/Tool/Sources/ActiveApplicationMonitor/ActiveApplicationMonitor.swift b/Tool/Sources/ActiveApplicationMonitor/ActiveApplicationMonitor.swift index bc7167b6..12c309ed 100644 --- a/Tool/Sources/ActiveApplicationMonitor/ActiveApplicationMonitor.swift +++ b/Tool/Sources/ActiveApplicationMonitor/ActiveApplicationMonitor.swift @@ -1,5 +1,35 @@ import AppKit +public struct RunningApplicationInfo: Sendable { + public let isXcode: Bool + public let isActive: Bool + public let isHidden: Bool + public let localizedName: String? + public let bundleIdentifier: String? + public let bundleURL: URL? + public let executableURL: URL? + public let processIdentifier: pid_t + public let launchDate: Date? + public let executableArchitecture: Int + + init(_ application: NSRunningApplication) { + isXcode = application.isXcode + isActive = application.isActive + isHidden = application.isHidden + localizedName = application.localizedName + bundleIdentifier = application.bundleIdentifier + bundleURL = application.bundleURL + executableURL = application.executableURL + processIdentifier = application.processIdentifier + launchDate = application.launchDate + executableArchitecture = application.executableArchitecture + } +} + +public extension NSRunningApplication { + var info: RunningApplicationInfo { RunningApplicationInfo(self) } +} + public final class ActiveApplicationMonitor { public static let shared = ActiveApplicationMonitor() public private(set) var latestXcode: NSRunningApplication? = NSWorkspace.shared @@ -17,11 +47,11 @@ public final class ActiveApplicationMonitor { } } - private var continuations: [UUID: AsyncStream.Continuation] = [:] + private var infoContinuations: [UUID: AsyncStream.Continuation] = [:] private init() { activeApplication = NSWorkspace.shared.runningApplications.first(where: \.isActive) - + Task { let sequence = NSWorkspace.shared.notificationCenter .notifications(named: NSWorkspace.didActivateApplicationNotification) @@ -36,7 +66,7 @@ public final class ActiveApplicationMonitor { } deinit { - for continuation in continuations { + for continuation in infoContinuations { continuation.value.finish() } } @@ -48,33 +78,33 @@ public final class ActiveApplicationMonitor { return nil } - public func createStream() -> AsyncStream { + public func createInfoStream() -> AsyncStream { .init { continuation in let id = UUID() Task { @MainActor in continuation.onTermination = { _ in - self.removeContinuation(id: id) + self.removeInfoContinuation(id: id) } - addContinuation(continuation, id: id) - continuation.yield(activeApplication) + addInfoContinuation(continuation, id: id) + continuation.yield(activeApplication?.info) } } } - func addContinuation( - _ continuation: AsyncStream.Continuation, + func addInfoContinuation( + _ continuation: AsyncStream.Continuation, id: UUID ) { - continuations[id] = continuation + infoContinuations[id] = continuation } - func removeContinuation(id: UUID) { - continuations[id] = nil + func removeInfoContinuation(id: UUID) { + infoContinuations[id] = nil } private func notifyContinuations() { - for continuation in continuations { - continuation.value.yield(activeApplication) + for continuation in infoContinuations { + continuation.value.yield(activeApplication?.info) } } } diff --git a/Tool/Sources/ChatContextCollector/ChatContextCollector.swift b/Tool/Sources/ChatContextCollector/ChatContextCollector.swift index e890a882..05a06da6 100644 --- a/Tool/Sources/ChatContextCollector/ChatContextCollector.swift +++ b/Tool/Sources/ChatContextCollector/ChatContextCollector.swift @@ -2,12 +2,32 @@ import Foundation import OpenAIService public struct ChatContext { + public struct RetrievedContent { + public var content: String + public var priority: Int + + public init(content: String, priority: Int) { + self.content = content + self.priority = priority + } + } + public var systemPrompt: String + public var retrievedContent: [RetrievedContent] public var functions: [any ChatGPTFunction] - public init(systemPrompt: String, functions: [any ChatGPTFunction]) { + public init( + systemPrompt: String, + retrievedContent: [RetrievedContent], + functions: [any ChatGPTFunction] + ) { self.systemPrompt = systemPrompt + self.retrievedContent = retrievedContent self.functions = functions } + + public static var empty: Self { + .init(systemPrompt: "", retrievedContent: [], functions: []) + } } public protocol ChatContextCollector { @@ -16,6 +36,6 @@ public protocol ChatContextCollector { scopes: Set, content: String, configuration: ChatGPTConfiguration - ) -> ChatContext? + ) async -> ChatContext } diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift b/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift similarity index 96% rename from Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift rename to Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift index a3a32ff4..bf297931 100644 --- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift +++ b/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift @@ -10,15 +10,15 @@ import XcodeInspector public final class ActiveDocumentChatContextCollector: ChatContextCollector { public init() {} - var activeDocumentContext: ActiveDocumentContext? + public var activeDocumentContext: ActiveDocumentContext? public func generateContext( history: [ChatMessage], scopes: Set, content: String, configuration: ChatGPTConfiguration - ) -> ChatContext? { - guard let info = getEditorInformation() else { return nil } + ) -> ChatContext { + guard let info = getEditorInformation() else { return .empty } let context = getActiveDocumentContext(info) activeDocumentContext = context @@ -28,10 +28,11 @@ public final class ActiveDocumentChatContextCollector: ChatContextCollector { removedCode.focusedContext = nil return .init( systemPrompt: extractSystemPrompt(removedCode), + retrievedContent: [], functions: [] ) } - return nil + return .empty } var functions = [any ChatGPTFunction]() @@ -66,6 +67,7 @@ public final class ActiveDocumentChatContextCollector: ChatContextCollector { return .init( systemPrompt: extractSystemPrompt(context), + retrievedContent: [], functions: functions ) } diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/ExpandFocusRangeFunction.swift b/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/ExpandFocusRangeFunction.swift similarity index 100% rename from Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/ExpandFocusRangeFunction.swift rename to Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/ExpandFocusRangeFunction.swift diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToCodeAroundLineFunction.swift b/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToCodeAroundLineFunction.swift similarity index 100% rename from Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToCodeAroundLineFunction.swift rename to Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToCodeAroundLineFunction.swift diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToFocusedCodeFunction.swift b/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToFocusedCodeFunction.swift similarity index 100% rename from Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToFocusedCodeFunction.swift rename to Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToFocusedCodeFunction.swift diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetEditorInfo.swift b/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetEditorInfo.swift similarity index 100% rename from Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetEditorInfo.swift rename to Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetEditorInfo.swift diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/LegacyActiveDocumentChatContextCollector.swift b/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/LegacyActiveDocumentChatContextCollector.swift similarity index 76% rename from Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/LegacyActiveDocumentChatContextCollector.swift rename to Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/LegacyActiveDocumentChatContextCollector.swift index 3e3af26b..45cc414c 100644 --- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/LegacyActiveDocumentChatContextCollector.swift +++ b/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/LegacyActiveDocumentChatContextCollector.swift @@ -1,9 +1,9 @@ +import ChatContextCollector import Foundation import OpenAIService import Preferences import SuggestionModel import XcodeInspector -import ChatContextCollector public struct LegacyActiveDocumentChatContextCollector: ChatContextCollector { public init() {} @@ -13,8 +13,8 @@ public struct LegacyActiveDocumentChatContextCollector: ChatContextCollector { scopes: Set, content: String, configuration: ChatGPTConfiguration - ) -> ChatContext? { - guard let content = getEditorInformation() else { return nil } + ) -> ChatContext { + guard let content = getEditorInformation() else { return .empty } let relativePath = content.relativePath let selectionRange = content.editorContent?.selections.first ?? .outOfScope let editorContent = { @@ -79,29 +79,29 @@ public struct LegacyActiveDocumentChatContextCollector: ChatContextCollector { return .init( systemPrompt: """ - Active Document Context:### - Document Relative Path: \(relativePath) - Selection Range Start: \ - Line \(selectionRange.start.line) \ - Character \(selectionRange.start.character) - Selection Range End: \ - Line \(selectionRange.end.line) \ - Character \(selectionRange.end.character) - Cursor Position: \ - Line \(selectionRange.end.line) \ - Character \(selectionRange.end.character) - \(editorContent) - Line Annotations: - \( - content.editorContent?.lineAnnotations - .map { " - \($0)" } - .joined(separator: "\n") ?? "N/A" - ) - ### - """, + Active Document Context:### + Document Relative Path: \(relativePath) + Selection Range Start: \ + Line \(selectionRange.start.line) \ + Character \(selectionRange.start.character) + Selection Range End: \ + Line \(selectionRange.end.line) \ + Character \(selectionRange.end.character) + Cursor Position: \ + Line \(selectionRange.end.line) \ + Character \(selectionRange.end.character) + \(editorContent) + Line Annotations: + \( + content.editorContent?.lineAnnotations + .map { " - \($0)" } + .joined(separator: "\n") ?? "N/A" + ) + ### + """, + retrievedContent: [], functions: [] ) } } - diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ReadableCursorRange.swift b/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ReadableCursorRange.swift similarity index 100% rename from Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ReadableCursorRange.swift rename to Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ReadableCursorRange.swift diff --git a/Core/Sources/CodeiumService/CodeiumAuthService.swift b/Tool/Sources/CodeiumService/CodeiumAuthService.swift similarity index 100% rename from Core/Sources/CodeiumService/CodeiumAuthService.swift rename to Tool/Sources/CodeiumService/CodeiumAuthService.swift diff --git a/Core/Sources/CodeiumService/CodeiumInstallationManager.swift b/Tool/Sources/CodeiumService/CodeiumInstallationManager.swift similarity index 100% rename from Core/Sources/CodeiumService/CodeiumInstallationManager.swift rename to Tool/Sources/CodeiumService/CodeiumInstallationManager.swift diff --git a/Core/Sources/CodeiumService/CodeiumLanguageServer.swift b/Tool/Sources/CodeiumService/CodeiumLanguageServer.swift similarity index 100% rename from Core/Sources/CodeiumService/CodeiumLanguageServer.swift rename to Tool/Sources/CodeiumService/CodeiumLanguageServer.swift diff --git a/Core/Sources/CodeiumService/CodeiumModels.swift b/Tool/Sources/CodeiumService/CodeiumModels.swift similarity index 100% rename from Core/Sources/CodeiumService/CodeiumModels.swift rename to Tool/Sources/CodeiumService/CodeiumModels.swift diff --git a/Core/Sources/CodeiumService/CodeiumRequest.swift b/Tool/Sources/CodeiumService/CodeiumRequest.swift similarity index 100% rename from Core/Sources/CodeiumService/CodeiumRequest.swift rename to Tool/Sources/CodeiumService/CodeiumRequest.swift diff --git a/Core/Sources/CodeiumService/CodeiumService.swift b/Tool/Sources/CodeiumService/CodeiumService.swift similarity index 100% rename from Core/Sources/CodeiumService/CodeiumService.swift rename to Tool/Sources/CodeiumService/CodeiumService.swift diff --git a/Core/Sources/CodeiumService/CodeiumSupportedLanguage.swift b/Tool/Sources/CodeiumService/CodeiumSupportedLanguage.swift similarity index 100% rename from Core/Sources/CodeiumService/CodeiumSupportedLanguage.swift rename to Tool/Sources/CodeiumService/CodeiumSupportedLanguage.swift diff --git a/Core/Sources/CodeiumService/OpendDocumentPool.swift b/Tool/Sources/CodeiumService/OpendDocumentPool.swift similarity index 100% rename from Core/Sources/CodeiumService/OpendDocumentPool.swift rename to Tool/Sources/CodeiumService/OpendDocumentPool.swift diff --git a/Tool/Sources/FocusedCodeFinder/SwiftFocusedCodeFinder.swift b/Tool/Sources/FocusedCodeFinder/SwiftFocusedCodeFinder.swift index 0c2ea908..52c4181b 100644 --- a/Tool/Sources/FocusedCodeFinder/SwiftFocusedCodeFinder.swift +++ b/Tool/Sources/FocusedCodeFinder/SwiftFocusedCodeFinder.swift @@ -1,9 +1,9 @@ import ASTParser import Foundation +import Preferences import SuggestionModel import SwiftParser import SwiftSyntax -import Preferences public struct SwiftFocusedCodeFinder: FocusedCodeFinder { public let maxFocusedCodeLineCount: Int @@ -61,7 +61,10 @@ public struct SwiftFocusedCodeFinder: FocusedCodeFinder { } } guard let focusedNode else { - var result = UnknownLanguageFocusedCodeFinder(proposedSearchRange: 5) + var result = + UnknownLanguageFocusedCodeFinder( + proposedSearchRange: maxFocusedCodeLineCount / 2 + ) .findFocusedCode( containingRange: range, activeDocumentContext: activeDocumentContext diff --git a/Core/Sources/GitHubCopilotService/CopilotLocalProcessServer.swift b/Tool/Sources/GitHubCopilotService/CopilotLocalProcessServer.swift similarity index 100% rename from Core/Sources/GitHubCopilotService/CopilotLocalProcessServer.swift rename to Tool/Sources/GitHubCopilotService/CopilotLocalProcessServer.swift diff --git a/Core/Sources/GitHubCopilotService/CustomStdioTransport.swift b/Tool/Sources/GitHubCopilotService/CustomStdioTransport.swift similarity index 100% rename from Core/Sources/GitHubCopilotService/CustomStdioTransport.swift rename to Tool/Sources/GitHubCopilotService/CustomStdioTransport.swift diff --git a/Core/Sources/GitHubCopilotService/GitHubCopilotAccountStatus.swift b/Tool/Sources/GitHubCopilotService/GitHubCopilotAccountStatus.swift similarity index 100% rename from Core/Sources/GitHubCopilotService/GitHubCopilotAccountStatus.swift rename to Tool/Sources/GitHubCopilotService/GitHubCopilotAccountStatus.swift diff --git a/Core/Sources/GitHubCopilotService/GitHubCopilotInstallationManager.swift b/Tool/Sources/GitHubCopilotService/GitHubCopilotInstallationManager.swift similarity index 100% rename from Core/Sources/GitHubCopilotService/GitHubCopilotInstallationManager.swift rename to Tool/Sources/GitHubCopilotService/GitHubCopilotInstallationManager.swift diff --git a/Core/Sources/GitHubCopilotService/GitHubCopilotRequest.swift b/Tool/Sources/GitHubCopilotService/GitHubCopilotRequest.swift similarity index 100% rename from Core/Sources/GitHubCopilotService/GitHubCopilotRequest.swift rename to Tool/Sources/GitHubCopilotService/GitHubCopilotRequest.swift diff --git a/Core/Sources/GitHubCopilotService/GitHubCopilotService.swift b/Tool/Sources/GitHubCopilotService/GitHubCopilotService.swift similarity index 99% rename from Core/Sources/GitHubCopilotService/GitHubCopilotService.swift rename to Tool/Sources/GitHubCopilotService/GitHubCopilotService.swift index 3c4a96b2..d3ac0034 100644 --- a/Core/Sources/GitHubCopilotService/GitHubCopilotService.swift +++ b/Tool/Sources/GitHubCopilotService/GitHubCopilotService.swift @@ -4,7 +4,6 @@ import LanguageServerProtocol import Logger import Preferences import SuggestionModel -import XPCShared public protocol GitHubCopilotAuthServiceType { func checkStatus() async throws -> GitHubCopilotAccountStatus diff --git a/Tool/Sources/OpenAIService/Configuration/UserPreferenceChatGPTConfiguration.swift b/Tool/Sources/OpenAIService/Configuration/UserPreferenceChatGPTConfiguration.swift index 4b83895b..81fc28a1 100644 --- a/Tool/Sources/OpenAIService/Configuration/UserPreferenceChatGPTConfiguration.swift +++ b/Tool/Sources/OpenAIService/Configuration/UserPreferenceChatGPTConfiguration.swift @@ -33,7 +33,7 @@ public struct UserPreferenceChatGPTConfiguration: ChatGPTConfiguration { } public var minimumReplyTokens: Int { - 300 + maxTokens / 5 } public var runFunctionsAutomatically: Bool { diff --git a/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemory.swift b/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemory.swift index 6385b565..8462c5ac 100644 --- a/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemory.swift +++ b/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemory.swift @@ -8,7 +8,8 @@ public actor AutoManagedChatGPTMemory: ChatGPTMemory { public private(set) var messages: [ChatMessage] = [] public private(set) var remainingTokens: Int? - public var systemPrompt: ChatMessage + public var systemPrompt: String + public var retrievedContent: [String] = [] public var history: [ChatMessage] = [] { didSet { onHistoryChange() } } @@ -25,7 +26,7 @@ public actor AutoManagedChatGPTMemory: ChatGPTMemory { configuration: ChatGPTConfiguration, functionProvider: ChatGPTFunctionProvider ) { - self.systemPrompt = .init(role: .system, content: systemPrompt) + self.systemPrompt = systemPrompt self.configuration = configuration self.functionProvider = functionProvider _ = Self.encoder // force pre-initialize @@ -36,7 +37,11 @@ public actor AutoManagedChatGPTMemory: ChatGPTMemory { } public func mutateSystemPrompt(_ newPrompt: String) { - systemPrompt.content = newPrompt + systemPrompt = newPrompt + } + + public func mutateRetrievedContent(_ newContent: [String]) { + retrievedContent = newContent } public nonisolated @@ -52,6 +57,17 @@ public actor AutoManagedChatGPTMemory: ChatGPTMemory { } /// https://github.com/openai/openai-cookbook/blob/main/examples/How_to_count_tokens_with_tiktoken.ipynb + /// + /// Format: + /// ``` + /// [System Prompt] priority: high + /// [Retrieved Content] priority: low + /// [Retrieved Content A] + /// + /// [Retrieved Content B] + /// [Functions] priority: high + /// [Message History] priority: medium + /// ``` func generateSendingHistory( maxNumberOfMessages: Int = UserDefaults.shared.value(for: \.chatGPTMaxMessageCount), encoder: TokenEncoder = AutoManagedChatGPTMemory.encoder @@ -63,8 +79,8 @@ public actor AutoManagedChatGPTMemory: ChatGPTMemory { return count } - var all: [ChatMessage] = [] - let systemMessageTokenCount = countToken(&systemPrompt) + var smallestSystemPromptMessage = ChatMessage(role: .system, content: systemPrompt) + let smallestSystemMessageTokenCount = countToken(&smallestSystemPromptMessage) let functionTokenCount = functionProvider.functions.reduce(into: 0) { partial, function in var count = encoder.countToken(text: function.name) + encoder.countToken(text: function.description) @@ -75,38 +91,83 @@ public actor AutoManagedChatGPTMemory: ChatGPTMemory { } partial += count } - var allTokensCount = functionTokenCount + - 3 // every reply is primed with <|start|>assistant<|message|> - allTokensCount += systemPrompt.isEmpty ? 0 : systemMessageTokenCount + let mandatoryContentTokensCount = smallestSystemMessageTokenCount + + functionTokenCount + + 3 // every reply is primed with <|start|>assistant<|message|> + + /// the available tokens count for other messages and retrieved content + let availableTokenCountForMessages = configuration.maxTokens + - configuration.minimumReplyTokens + - mandatoryContentTokensCount + + var messageTokenCount = 0 + var allMessages: [ChatMessage] = [] for (index, message) in history.enumerated().reversed() { - if maxNumberOfMessages > 0, all.count >= maxNumberOfMessages { break } + if maxNumberOfMessages > 0, allMessages.count >= maxNumberOfMessages { break } if message.isEmpty { continue } let tokensCount = countToken(&history[index]) - if tokensCount + allTokensCount > - configuration.maxTokens - configuration.minimumReplyTokens - { - break + if tokensCount + messageTokenCount > availableTokenCountForMessages { break } + messageTokenCount += tokensCount + allMessages.append(message) + } + + /// the available tokens count for retrieved content + let availableTokenCountForRetrievedContent = min( + availableTokenCountForMessages - messageTokenCount, + configuration.maxTokens / 2 + ) + var retrievedContentTokenCount = 0 + + let separator = String(repeating: "=", count: 32) // only 1 token + + var systemPrompt = systemPrompt + + func appendToSystemPrompt(_ text: String) -> Bool { + let tokensCount = encoder.countToken(text: text) + if tokensCount + retrievedContentTokenCount > + availableTokenCountForRetrievedContent { return false } + retrievedContentTokenCount += tokensCount + systemPrompt += text + return true + } + + for (index, content) in retrievedContent.filter({ !$0.isEmpty }).enumerated() { + if index == 0 { + if !appendToSystemPrompt(""" + + Below are information related to the conversation, separated by \(separator) + + """) { break } + } else { + if !appendToSystemPrompt("\n\(separator)\n") { break } } - allTokensCount += tokensCount - all.append(message) + + if !appendToSystemPrompt(content) { break } } if !systemPrompt.isEmpty { - all.append(systemPrompt) + let message = ChatMessage(role: .system, content: systemPrompt) + allMessages.append(message) } #if DEBUG Logger.service.info(""" Sending tokens count - - system prompt: \(systemMessageTokenCount) + - system prompt: \(smallestSystemMessageTokenCount) - functions: \(functionTokenCount) - - total: \(allTokensCount) - + - messages: \(messageTokenCount) + - retrieved content: \(retrievedContentTokenCount) + - total: \( + smallestSystemMessageTokenCount + + functionTokenCount + + messageTokenCount + + retrievedContentTokenCount + ) """) #endif - return all.reversed() + return allMessages.reversed() } func generateRemainingTokens( diff --git a/Tool/Sources/Preferences/Keys.swift b/Tool/Sources/Preferences/Keys.swift index eee92784..276f1e3d 100644 --- a/Tool/Sources/Preferences/Keys.swift +++ b/Tool/Sources/Preferences/Keys.swift @@ -173,7 +173,7 @@ public extension UserDefaultPreferenceKeys { } var gitHubCopilotIgnoreTrailingNewLines: PreferenceKey { - .init(defaultValue: false, key: "GitHubCopilotIgnoreTrailingNewLines") + .init(defaultValue: true, key: "GitHubCopilotIgnoreTrailingNewLines") } } @@ -318,7 +318,7 @@ public extension UserDefaultPreferenceKeys { } var acceptSuggestionWithTab: PreferenceKey { - .init(defaultValue: false, key: "AcceptSuggestionWithTab") + .init(defaultValue: true, key: "AcceptSuggestionWithTab") } } @@ -384,6 +384,10 @@ public extension UserDefaultPreferenceKeys { var chatSearchPluginMaxIterations: PreferenceKey { .init(defaultValue: 3, key: "ChatSearchPluginMaxIterations") } + + var wrapCodeInChatCodeBlock: PreferenceKey { + .init(defaultValue: true, key: "WrapCodeInChatCodeBlock") + } } // MARK: - Bing Search diff --git a/Tool/Sources/SharedUIComponents/CustomScrollView.swift b/Tool/Sources/SharedUIComponents/CustomScrollView.swift index f66cb3fa..0eb486f0 100644 --- a/Tool/Sources/SharedUIComponents/CustomScrollView.swift +++ b/Tool/Sources/SharedUIComponents/CustomScrollView.swift @@ -43,6 +43,13 @@ public struct CustomScrollView: View { .modifier(CustomScrollViewUpdateHeightModifier()) } .listStyle(.plain) + .modify { view in + if #available(macOS 13.0, *) { + view.listRowSeparator(.hidden).listSectionSeparator(.hidden) + } else { + view + } + } .frame(idealHeight: max(10, height)) .onPreferenceChange(CustomScrollViewHeightPreferenceKey.self) { newHeight in Task { @MainActor in diff --git a/Tool/Sources/SharedUIComponents/View+Modify.swift b/Tool/Sources/SharedUIComponents/View+Modify.swift new file mode 100644 index 00000000..59820772 --- /dev/null +++ b/Tool/Sources/SharedUIComponents/View+Modify.swift @@ -0,0 +1,10 @@ +import SwiftUI + +public extension View { + @ViewBuilder func modify(@ViewBuilder transform: (Self) -> Content) + -> some View + { + transform(self) + } +} + diff --git a/Core/Sources/SuggestionService/CodeiumSuggestionProvider.swift b/Tool/Sources/SuggestionService/CodeiumSuggestionProvider.swift similarity index 100% rename from Core/Sources/SuggestionService/CodeiumSuggestionProvider.swift rename to Tool/Sources/SuggestionService/CodeiumSuggestionProvider.swift diff --git a/Core/Sources/SuggestionService/GitHubCopilotSuggestionProvider.swift b/Tool/Sources/SuggestionService/GitHubCopilotSuggestionProvider.swift similarity index 100% rename from Core/Sources/SuggestionService/GitHubCopilotSuggestionProvider.swift rename to Tool/Sources/SuggestionService/GitHubCopilotSuggestionProvider.swift diff --git a/Core/Sources/SuggestionService/SuggestionService.swift b/Tool/Sources/SuggestionService/SuggestionService.swift similarity index 100% rename from Core/Sources/SuggestionService/SuggestionService.swift rename to Tool/Sources/SuggestionService/SuggestionService.swift diff --git a/Core/Sources/Service/WorkspaceExtension/Filespace+SuggestionService.swift b/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift similarity index 84% rename from Core/Sources/Service/WorkspaceExtension/Filespace+SuggestionService.swift rename to Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift index 9d8437e5..ae773b96 100644 --- a/Core/Sources/Service/WorkspaceExtension/Filespace+SuggestionService.swift +++ b/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift @@ -2,17 +2,22 @@ import Foundation import SuggestionModel import Workspace -struct FilespaceSuggestionSnapshot: Equatable { - var linesHash: Int - var cursorPosition: CursorPosition +public struct FilespaceSuggestionSnapshot: Equatable { + public var linesHash: Int + public var cursorPosition: CursorPosition + + public init(linesHash: Int, cursorPosition: CursorPosition) { + self.linesHash = linesHash + self.cursorPosition = cursorPosition + } } -struct FilespaceSuggestionSnapshotKey: FilespacePropertyKey { - static func createDefaultValue() +public struct FilespaceSuggestionSnapshotKey: FilespacePropertyKey { + public static func createDefaultValue() -> FilespaceSuggestionSnapshot { .init(linesHash: -1, cursorPosition: .outOfScope) } } -extension FilespacePropertyValues { +public extension FilespacePropertyValues { @WorkspaceActor var suggestionSourceSnapshot: FilespaceSuggestionSnapshot { get { self[FilespaceSuggestionSnapshotKey.self] } @@ -20,7 +25,7 @@ extension FilespacePropertyValues { } } -extension Filespace { +public extension Filespace { @WorkspaceActor func resetSnapshot() { // swiftformat:disable redundantSelf diff --git a/Core/Sources/Service/WorkspaceExtension/SuggestionWorkspacePlugin.swift b/Tool/Sources/WorkspaceSuggestionService/SuggestionWorkspacePlugin.swift similarity index 82% rename from Core/Sources/Service/WorkspaceExtension/SuggestionWorkspacePlugin.swift rename to Tool/Sources/WorkspaceSuggestionService/SuggestionWorkspacePlugin.swift index 9dd2d63f..7d17b684 100644 --- a/Core/Sources/Service/WorkspaceExtension/SuggestionWorkspacePlugin.swift +++ b/Tool/Sources/WorkspaceSuggestionService/SuggestionWorkspacePlugin.swift @@ -6,7 +6,7 @@ import SuggestionService import SuggestionModel import Preferences -final class SuggestionServiceWorkspacePlugin: WorkspacePlugin { +public final class SuggestionServiceWorkspacePlugin: WorkspacePlugin { let userDefaultsObserver = UserDefaultsObserver( object: UserDefaults.shared, forKeyPaths: [ UserDefaultPreferenceKeys().suggestionFeatureEnabledProjectList.key, @@ -14,13 +14,13 @@ final class SuggestionServiceWorkspacePlugin: WorkspacePlugin { ], context: nil ) - var isRealtimeSuggestionEnabled: Bool { + public var isRealtimeSuggestionEnabled: Bool { UserDefaults.shared.value(for: \.realtimeSuggestionToggle) } private var _suggestionService: SuggestionServiceType? - var suggestionService: SuggestionServiceType? { + public var suggestionService: SuggestionServiceType? { // Check if the workspace is disabled. let isSuggestionDisabledGlobally = UserDefaults.shared .value(for: \.disableSuggestionFeatureGlobally) @@ -45,7 +45,7 @@ final class SuggestionServiceWorkspacePlugin: WorkspacePlugin { return _suggestionService } - var isSuggestionFeatureEnabled: Bool { + public var isSuggestionFeatureEnabled: Bool { let isSuggestionDisabledGlobally = UserDefaults.shared .value(for: \.disableSuggestionFeatureGlobally) if isSuggestionDisabledGlobally { @@ -57,7 +57,7 @@ final class SuggestionServiceWorkspacePlugin: WorkspacePlugin { return true } - override init(workspace: Workspace) { + public override init(workspace: Workspace) { super.init(workspace: workspace) userDefaultsObserver.onChange = { [weak self] in @@ -66,25 +66,25 @@ final class SuggestionServiceWorkspacePlugin: WorkspacePlugin { } } - override func didOpenFilespace(_ filespace: Filespace) { + public override func didOpenFilespace(_ filespace: Filespace) { notifyOpenFile(filespace: filespace) } - override func didSaveFilespace(_ filespace: Filespace) { + public override func didSaveFilespace(_ filespace: Filespace) { notifySaveFile(filespace: filespace) } - override func didUpdateFilespace(_ filespace: Filespace, content: String) { + public override func didUpdateFilespace(_ filespace: Filespace, content: String) { notifyUpdateFile(filespace: filespace, content: content) } - override func didCloseFilespace(_ fileURL: URL) { + public override func didCloseFilespace(_ fileURL: URL) { Task { try await suggestionService?.notifyCloseTextDocument(fileURL: fileURL) } } - func notifyOpenFile(filespace: Filespace) { + public func notifyOpenFile(filespace: Filespace) { workspace?.refreshUpdateTime() workspace?.openedFileRecoverableStorage.openFile(fileURL: filespace.fileURL) Task { @@ -102,7 +102,7 @@ final class SuggestionServiceWorkspacePlugin: WorkspacePlugin { } } - func notifyUpdateFile(filespace: Filespace, content: String) { + public func notifyUpdateFile(filespace: Filespace, content: String) { filespace.refreshUpdateTime() workspace?.refreshUpdateTime() Task { @@ -113,7 +113,7 @@ final class SuggestionServiceWorkspacePlugin: WorkspacePlugin { } } - func notifySaveFile(filespace: Filespace) { + public func notifySaveFile(filespace: Filespace) { filespace.refreshUpdateTime() workspace?.refreshUpdateTime() Task { @@ -121,7 +121,7 @@ final class SuggestionServiceWorkspacePlugin: WorkspacePlugin { } } - func terminateSuggestionService() async { + public func terminateSuggestionService() async { await _suggestionService?.terminate() } } diff --git a/Core/Sources/Service/WorkspaceExtension/Workspace+SuggestionService.swift b/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift similarity index 97% rename from Core/Sources/Service/WorkspaceExtension/Workspace+SuggestionService.swift rename to Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift index de9df342..2aa15bf8 100644 --- a/Core/Sources/Service/WorkspaceExtension/Workspace+SuggestionService.swift +++ b/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift @@ -4,7 +4,7 @@ import SuggestionService import Workspace import XPCShared -extension Workspace { +public extension Workspace { var suggestionPlugin: SuggestionServiceWorkspacePlugin? { plugin(for: SuggestionServiceWorkspacePlugin.self) } @@ -18,13 +18,13 @@ extension Workspace { } struct SuggestionFeatureDisabledError: Error, LocalizedError { - var errorDescription: String? { + public var errorDescription: String? { "Suggestion feature is disabled for this project." } } } -extension Workspace { +public extension Workspace { @WorkspaceActor @discardableResult func generateSuggestions( @@ -59,7 +59,7 @@ extension Workspace { usesTabsForIndentation: editor.usesTabsForIndentation, ignoreSpaceOnlySuggestions: true ) - + filespace.setSuggestions(completions) return completions diff --git a/Core/Sources/XPCShared/Models.swift b/Tool/Sources/XPCShared/Models.swift similarity index 100% rename from Core/Sources/XPCShared/Models.swift rename to Tool/Sources/XPCShared/Models.swift diff --git a/Core/Sources/XPCShared/XPCServiceProtocol.swift b/Tool/Sources/XPCShared/XPCServiceProtocol.swift similarity index 100% rename from Core/Sources/XPCShared/XPCServiceProtocol.swift rename to Tool/Sources/XPCShared/XPCServiceProtocol.swift diff --git a/Core/Tests/ActiveDocumentChatContextCollectorTests/SwiftFocusedCodeFinderTests.swift b/Tool/Tests/ActiveDocumentChatContextCollectorTests/SwiftFocusedCodeFinderTests.swift similarity index 100% rename from Core/Tests/ActiveDocumentChatContextCollectorTests/SwiftFocusedCodeFinderTests.swift rename to Tool/Tests/ActiveDocumentChatContextCollectorTests/SwiftFocusedCodeFinderTests.swift diff --git a/Core/Tests/ActiveDocumentChatContextCollectorTests/UnknownLanguageFocusedCodeFinderTests.swift b/Tool/Tests/ActiveDocumentChatContextCollectorTests/UnknownLanguageFocusedCodeFinderTests.swift similarity index 100% rename from Core/Tests/ActiveDocumentChatContextCollectorTests/UnknownLanguageFocusedCodeFinderTests.swift rename to Tool/Tests/ActiveDocumentChatContextCollectorTests/UnknownLanguageFocusedCodeFinderTests.swift diff --git a/Core/Tests/GitHubCopilotServiceTests/FetchSuggestionsTests.swift b/Tool/Tests/GitHubCopilotServiceTests/FetchSuggestionsTests.swift similarity index 100% rename from Core/Tests/GitHubCopilotServiceTests/FetchSuggestionsTests.swift rename to Tool/Tests/GitHubCopilotServiceTests/FetchSuggestionsTests.swift diff --git a/Core/Tests/GitHubCopilotServiceTests/FileExtensionToLanguageIdentifierTests.swift b/Tool/Tests/GitHubCopilotServiceTests/FileExtensionToLanguageIdentifierTests.swift similarity index 100% rename from Core/Tests/GitHubCopilotServiceTests/FileExtensionToLanguageIdentifierTests.swift rename to Tool/Tests/GitHubCopilotServiceTests/FileExtensionToLanguageIdentifierTests.swift diff --git a/Version.xcconfig b/Version.xcconfig index 36b91970..35911ea7 100644 --- a/Version.xcconfig +++ b/Version.xcconfig @@ -1,3 +1,3 @@ -APP_VERSION = 0.24.1 -APP_BUILD = 251 +APP_VERSION = 0.25.0 +APP_BUILD = 261 diff --git a/appcast.xml b/appcast.xml index f75f5043..ae698149 100644 --- a/appcast.xml +++ b/appcast.xml @@ -3,6 +3,18 @@ Copilot for Xcode + + 0.25.0 + Wed, 11 Oct 2023 23:08:08 +0800 + 261 + 0.25.0 + 12.0 + + https://github.com/intitni/CopilotForXcode/releases/tag/0.25.0 + + + + 0.24.1 Fri, 29 Sep 2023 14:35:35 +0800